Files
VoiceStudio/tests/test_loopback_server_mode.py
T
Palash DebnathandClaude Opus 4.8 b4f1fe18d7 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>
2026-06-03 06:22:25 +05:30

56 lines
1.9 KiB
Python

"""`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"))