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>
56 lines
1.9 KiB
Python
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"))
|