fix(server): relax loopback gate in headless server mode so Docker admin UI works (#261) (#263)

In Docker the loopback origin gate (`require_loopback`) is unenforceable:
Docker's NAT rewrites `request.client.host` to the bridge gateway (e.g.
172.17.0.1) even for a localhost-only `-p 127.0.0.1:3900:3900` mapping, so every
request looks non-loopback. The gate then 403s the operator out of the routes
the web UI needs — `/system/*` (incl. `/system/info`, which left the version
blank, re-breaking #249 in Docker) and `/api/settings/*` (HF-token entry) —
surfacing as "Loopback origin required" all over the UI.

Fix: add an explicit, opt-in `OMNIVOICE_SERVER_MODE` flag. When set,
`require_loopback` becomes a no-op; exposure is then governed by the operator's
port mapping plus the optional share PIN (NetworkAccessMiddleware still 401s
unauthenticated non-loopback clients whenever a PIN is set). The Docker image
sets `OMNIVOICE_SERVER_MODE=1` (Dockerfile + documented in compose).

Security: the desktop build NEVER sets this, so its loopback boundary is
unchanged — LAN share guests are still denied the admin/system routes. New
unit tests lock the contract (strict 403 by default incl. the PR #81 vectors;
relaxed only under the flag). Existing non-loopback 403 tests still pass.

Docs: docker.md troubleshooting entry for "Loopback origin required".

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-03 06:22:25 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent cec15070a5
commit b4f1fe18d7
5 changed files with 115 additions and 3 deletions
+35 -3
View File
@@ -5,9 +5,12 @@ These are intentionally tiny — one concern per dependency — so they can be
composed at the route or router level without surprises.
Currently exposed:
- `require_loopback`: 403 unless the request came from a loopback origin.
- `require_loopback`: 403 unless the request came from a loopback origin
(bypassed in explicit server mode — see `_server_mode`).
"""
import os
from fastapi import HTTPException, Request
@@ -19,6 +22,28 @@ from fastapi import HTTPException, Request
# the guard: nothing here matches a non-loopback origin.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
_TRUTHY = frozenset({"1", "true", "yes", "on"})
def _server_mode() -> bool:
"""Whether this process is a headless server deployment (Docker image).
In Docker the loopback gate is *unenforceable*: Docker's network NAT
rewrites ``request.client.host`` to the bridge gateway (e.g. 172.17.0.1)
even for a localhost-only ``-p 127.0.0.1:3900:3900`` mapping, so every
request looks non-loopback and the gate 403s the operator out of the
system/settings routes they need (issue #261 — incl. ``/system/info``,
which blanks the version display).
The Docker image sets ``OMNIVOICE_SERVER_MODE=1`` to opt out of the gate.
Network exposure then rests on the operator's port mapping plus the
optional share PIN (``NetworkAccessMiddleware`` still 401s unauthenticated
non-loopback clients whenever a PIN is set). The desktop build never sets
this, so its loopback boundary — including denying LAN share guests access
to admin routes — is unchanged. Read at call time so it stays testable.
"""
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
def require_loopback(request: Request) -> None:
"""Reject any request whose `client.host` is not a loopback address.
@@ -35,7 +60,14 @@ def require_loopback(request: Request) -> None:
Returns None on success (FastAPI dependency convention). Raises 403
on rejection — the response body is `{"detail": "loopback origin required"}`
so existing tests for `/system/set-env` keep passing without modification.
In server mode (Docker, see `_server_mode`) the gate is a no-op: the
loopback origin is unenforceable there and exposure is governed by the
deployment's port mapping + the optional share PIN instead.
"""
host = request.client.host if request.client else None
if host not in _LOOPBACK_HOSTS:
raise HTTPException(status_code=403, detail="loopback origin required")
if host in _LOOPBACK_HOSTS:
return
if _server_mode():
return
raise HTTPException(status_code=403, detail="loopback origin required")
+7
View File
@@ -29,6 +29,13 @@ ENV HF_HOME=/app/omnivoice_data/huggingface
# Allow bare imports (from core.config, from services.*, etc.) when
# uvicorn is started as `backend.main:app` from WORKDIR /app.
ENV PYTHONPATH=/app/backend
# Headless server deployment: relax the desktop-only loopback origin gate.
# Docker's network NAT rewrites the client host to the bridge gateway, so the
# gate would otherwise 403 the operator out of /system/* and /api/settings/*
# ("Loopback origin required", issue #261). Exposure is governed by the
# operator's `-p` port mapping plus the optional share PIN. Desktop builds
# never set this, so their loopback boundary is unchanged.
ENV OMNIVOICE_SERVER_MODE=1
# Install system dependencies (FFmpeg is critical for torchaudio/scene splitting)
RUN apt-get update && apt-get install -y --no-install-recommends \
+9
View File
@@ -46,6 +46,12 @@ services:
# OMNIVOICE_BIND_HOST=0.0.0.0 here only opens the container's own
# interface. The backend default is 127.0.0.1 (see backend/main.py).
- OMNIVOICE_BIND_HOST=0.0.0.0
# Headless server: relax the desktop-only loopback origin gate so the
# web UI's /system/* and /api/settings/* routes work through Docker's
# NAT (issue #261). Already baked into the image; shown here so it's
# discoverable. If you front the container with your own auth proxy on
# loopback, set this to 0 to re-enable the strict gate.
- OMNIVOICE_SERVER_MODE=1
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3900/health"]
interval: 30s
@@ -76,6 +82,9 @@ services:
# service above. The host-side `127.0.0.1:3900:3900` mapping keeps
# LAN reachability off by default.
- OMNIVOICE_BIND_HOST=0.0.0.0
# See the CPU service above — relaxes the loopback origin gate for the
# headless Docker deployment (issue #261). Set to 0 to re-enable it.
- OMNIVOICE_SERVER_MODE=1
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3900/health"]
interval: 30s
+9
View File
@@ -118,6 +118,15 @@ Two paths are worth persisting across container restarts:
The running version is now shown in **Settings → About → Version** (read live
from the backend), so the web UI no longer displays a dash in Docker.
- **Checking which version is running:** `docker exec omnivoice python -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`.
- **"Loopback origin required" errors (and a blank version):** the desktop
build restricts the `/system/*` and `/api/settings/*` routes to a loopback
origin, but Docker's NAT makes every request look non-loopback, so the gate
used to 403 the whole admin UI (issue #261). The image now ships with
`OMNIVOICE_SERVER_MODE=1`, which relaxes that gate for the headless
deployment — exposure is instead governed by your `-p` port mapping (keep the
`127.0.0.1:` prefix to stay local) plus the optional share PIN. If you front
the container with your own auth proxy on loopback, set `OMNIVOICE_SERVER_MODE=0`
to re-enable the strict gate.
- **Media-preview 404 in LAN mode:** see the [LAN access](#lan-access) section
above — the `window.location.host` fix shipped in v0.3.
- **GPU not detected:** verify `docker run --rm --gpus all nvidia/cuda:12.8.0-base-ubuntu22.04 nvidia-smi` succeeds first.
+55
View File
@@ -0,0 +1,55 @@
"""`require_loopback` gate contract (issue #261).
The gate must stay strict on the desktop build (non-loopback → 403, which is the
PR #81 trust boundary), but become a no-op in the headless Docker server mode,
where Docker's NAT makes the loopback origin unenforceable and exposure is
governed by the port mapping + the share PIN instead.
"""
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from api.dependencies import require_loopback
def _req(host):
"""Minimal stand-in for a Starlette Request — the gate only reads client.host."""
return SimpleNamespace(client=SimpleNamespace(host=host) if host else None)
@pytest.fixture(autouse=True)
def _clear_server_mode(monkeypatch):
# Start each test from the desktop default regardless of the ambient env.
monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False)
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"])
def test_loopback_always_allowed(host):
require_loopback(_req(host)) # must not raise
def test_non_loopback_rejected_by_default():
with pytest.raises(HTTPException) as exc:
require_loopback(_req("172.17.0.1")) # Docker bridge gateway
assert exc.value.status_code == 403
assert "loopback" in str(exc.value.detail).lower()
def test_missing_client_rejected_by_default():
with pytest.raises(HTTPException):
require_loopback(_req(None))
@pytest.mark.parametrize("val", ["1", "true", "TRUE", "yes", "on"])
def test_server_mode_allows_non_loopback(monkeypatch, val):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", val)
require_loopback(_req("172.17.0.1")) # must not raise
require_loopback(_req("127.0.0.1")) # loopback still fine
@pytest.mark.parametrize("val", ["0", "false", "no", "", "off"])
def test_falsey_server_mode_keeps_gate_strict(monkeypatch, val):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", val)
with pytest.raises(HTTPException):
require_loopback(_req("10.0.0.5"))