"""`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 is_loopback, is_local_host, require_local, 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_loopback_env(monkeypatch): # Start each test from the strict desktop default regardless of ambient env. monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False) monkeypatch.delenv("OMNIVOICE_TRUSTED_NETWORKS", 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")) # Trusted local networks (OMNIVOICE_TRUSTED_NETWORKS) — issue #1170. # A self-hoster can name CIDRs treated as trusted by the CONSUMPTION gates # (PIN/API-key/WS), so a LAN or reverse proxy is exempted. Admin gates # (require_loopback) stay true-loopback-only — two-tier privilege model. @pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"]) def test_is_loopback_true_for_loopback_only(host): assert is_loopback(host) is True @pytest.mark.parametrize("host", ["192.168.1.50", "10.0.0.1", "8.8.8.8"]) def test_is_loopback_false_for_non_loopback(monkeypatch, host): monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "192.168.1.0/24") assert is_loopback(host) is False # trusted-network ≠ loopback @pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"]) def test_is_local_host_loopback_always(monkeypatch, host): monkeypatch.delenv("OMNIVOICE_TRUSTED_NETWORKS", raising=False) assert is_local_host(host) is True def test_is_local_host_trusts_configured_cidr(monkeypatch): monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "192.168.1.0/24,10.0.0.0/8") assert is_local_host("192.168.1.50") is True assert is_local_host("10.5.5.5") is True def test_is_local_host_rejects_outside_configured_cidr(monkeypatch): monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "192.168.1.0/24") assert is_local_host("8.8.8.8") is False assert is_local_host("192.168.2.1") is False # adjacent subnet @pytest.mark.parametrize("host", ["192.168.1.5", "example.com"]) def test_is_local_host_untrusted_without_config(monkeypatch, host): # No trust configured → no behavior change vs. the desktop default. monkeypatch.delenv("OMNIVOICE_TRUSTED_NETWORKS", raising=False) assert is_local_host(host) is False def test_is_local_host_ignores_malformed_cidr(monkeypatch): # A garbage entry is skipped, not fatal — the gate must never wedge. monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "not-a-cidr,192.168.1.0/24") assert is_local_host("192.168.1.5") is True assert is_local_host("8.8.8.8") is False def test_require_loopback_rejects_trusted_network(monkeypatch): # Admin gate stays true-loopback-only: a trusted CIDR exempts consumption # (PIN/API-key/WS) but NOT admin routes like /system/set-env (RCE-class). monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "172.16.0.0/12") with pytest.raises(HTTPException) as exc: require_loopback(_req("172.20.0.9")) assert exc.value.status_code == 403 def test_require_loopback_still_rejects_untrusted_non_loopback(monkeypatch): monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "172.16.0.0/12") with pytest.raises(HTTPException) as exc: require_loopback(_req("8.8.8.8")) assert exc.value.status_code == 403 def test_require_local_allows_trusted_network(monkeypatch): # Consumption-tier: a trusted-network client IS exempted (unlike require_loopback). monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "172.16.0.0/12") require_local(_req("172.20.0.9")) # must not raise def test_require_local_rejects_untrusted_non_loopback(monkeypatch): monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "172.16.0.0/12") with pytest.raises(HTTPException) as exc: require_local(_req("8.8.8.8")) assert exc.value.status_code == 403 def _req_full(host, *, headers=None, query=None, cookies=None, pin=None): """Richer stub carrying the channels the admin-credential check reads: headers, query params, cookies, and app.state.network_share.pin.""" ns = SimpleNamespace(pin=pin) if pin is not None else None app = SimpleNamespace(state=SimpleNamespace(network_share=ns)) return SimpleNamespace( client=SimpleNamespace(host=host) if host else None, headers=headers or {}, query_params=query or {}, cookies=cookies or {}, app=app, ) # Server mode + trusted network + credential — issue #1213. # Regression for the two-tier collapse: with OMNIVOICE_SERVER_MODE=1 the # loopback origin is unenforceable, so admin can't require true loopback. But a # configured credential (API key / PIN) must still gate admin — a trusted-network # client that presents NO credential must NOT reach /system/* or /api/settings/* # just because is_local_host exempts it from the consumption middleware. def test_server_mode_trusted_network_no_credential_reaches_admin(monkeypatch): # No credential configured → admin stays open in server mode (the #261 # Docker flow: operator reaches /system/* off the bridge gateway). monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "10.0.0.0/8") monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) require_loopback(_req_full("10.1.2.3")) # must not raise def test_server_mode_trusted_network_blocked_when_api_key_set(monkeypatch): # THE FIX: API key set to lock the backend + trusted CIDR for consumption. # A trusted-network client with NO key must be 403'd on the admin surface — # trusted-network membership is a consumption exemption, never admin. monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "10.0.0.0/8") monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") with pytest.raises(HTTPException) as exc: require_loopback(_req_full("10.1.2.3")) assert exc.value.status_code == 403 # ...but consumption stays exempt for that same trusted client. require_local(_req_full("10.1.2.3")) # must not raise def test_server_mode_admin_allowed_with_api_key_header(monkeypatch): monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") require_loopback( _req_full("172.17.0.1", headers={"authorization": "Bearer s3cret"}) ) # must not raise def test_server_mode_admin_allowed_with_api_key_cookie_or_query(monkeypatch): monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") require_loopback(_req_full("172.17.0.1", cookies={"ov_key": "s3cret"})) require_loopback(_req_full("172.17.0.1", query={"api_key": "s3cret"})) def test_server_mode_admin_rejects_wrong_api_key(monkeypatch): monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") with pytest.raises(HTTPException) as exc: require_loopback(_req_full("172.17.0.1", headers={"authorization": "Bearer nope"})) assert exc.value.status_code == 403 def test_server_mode_pin_does_not_unlock_admin(monkeypatch): # CodeRabbit #1213: the 6-digit share PIN is a CONSUMPTION credential and is # brute-forceable (10^6, no lockout), so it must NEVER gate the RCE-class # admin surface. With a PIN set but no API key, admin is still *gated* (not # left open) — but only loopback or the long API key can reach it. Presenting # even the correct PIN over the network does NOT unlock admin. monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "10.0.0.0/8") monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) # No PIN presented → 403. with pytest.raises(HTTPException): require_loopback(_req_full("10.1.2.3", pin="1234")) # Correct PIN presented → STILL 403 (the PIN never gates admin). with pytest.raises(HTTPException): require_loopback(_req_full("10.1.2.3", pin="1234", headers={"x-omnivoice-pin": "1234"})) # Loopback admin still needs no credential (the local operator path)… require_loopback(_req_full("127.0.0.1", pin="1234")) # …and the trusted client keeps its consumption exemption. require_local(_req_full("10.1.2.3")) def test_server_mode_loopback_admin_never_needs_credential(monkeypatch): # The local operator on the Docker host (loopback) reaches admin with no # credential even when one is configured — the desktop shell path. monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") require_loopback(_req_full("127.0.0.1")) # must not raise def test_is_local_host_unwraps_ipv4_mapped_ipv6(monkeypatch): # Dual-stack proxies (Caddy, Node.js) pass ::ffff:192.168.1.5 — should # match an IPv4 CIDR after unwrapping the mapped address. monkeypatch.setenv("OMNIVOICE_TRUSTED_NETWORKS", "192.168.1.0/24") assert is_local_host("::ffff:192.168.1.5") is True assert is_local_host("::ffff:8.8.8.8") is False