feat(dashboard): add should_require_auth predicate for OAuth gate

Phase 0, Task 0.2. Single source of truth for 'is the auth gate active?'.
Reuses the existing _LOOPBACK_HOST_VALUES frozenset so this stays in sync
with the DNS-rebinding host-header check. RFC1918/CGNAT/link-local are
treated as public — exact threat model the gate exists for.
This commit is contained in:
Ben 2026-05-21 15:01:54 +10:00 committed by Teknium
parent f2b479e7a2
commit 8773bbf186
2 changed files with 38 additions and 0 deletions

View file

@ -160,6 +160,22 @@ _LOOPBACK_HOST_VALUES: frozenset = frozenset({
})
def should_require_auth(host: str, allow_public: bool) -> bool:
"""Return True iff the dashboard OAuth auth gate must be active.
Truth table:
host == loopback False (no auth)
host != loopback AND allow_public (--insecure) False (legacy escape hatch)
host != loopback AND NOT allow_public True (gate engages)
"Loopback" matches the same set used by ``--insecure`` enforcement in
``start_server``: 127.0.0.1, localhost, ::1. RFC1918 / CGNAT / link-local
are deliberately treated as PUBLIC a hostile device on the same LAN is
exactly the threat model the gate is designed for.
"""
return (host not in _LOOPBACK_HOST_VALUES) and (not allow_public)
def _is_accepted_host(host_header: str, bound_host: str) -> bool:
"""True if the Host header targets the interface we bound to.

View file

@ -69,3 +69,25 @@ def test_loopback_host_header_validation_still_enforced(client_loopback):
"""DNS-rebinding protection: a foreign Host header is rejected."""
r = client_loopback.get("/api/status", headers={"Host": "evil.test"})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# should_require_auth predicate (Task 0.2)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("host,allow_public,expected", [
("127.0.0.1", False, False),
("127.0.0.1", True, False),
("localhost", False, False),
("::1", False, False),
("0.0.0.0", True, False), # --insecure escape hatch
("0.0.0.0", False, True),
("192.168.1.5", False, True),
("10.0.0.1", True, False),
("100.64.0.1", False, True), # Tailscale CGNAT — treated as public
("hermes-agent-prod-abc.fly.dev", False, True),
])
def test_should_require_auth_truth_table(host, allow_public, expected):
from hermes_cli.web_server import should_require_auth
assert should_require_auth(host, allow_public) is expected