From 8773bbf186ca0c6e679a3534407c3078b5e17dda Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 21 May 2026 15:01:54 +1000 Subject: [PATCH] feat(dashboard): add should_require_auth predicate for OAuth gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/web_server.py | 16 ++++++++++++++ tests/hermes_cli/test_dashboard_auth_gate.py | 22 ++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8d9f69a6f0f..b3255166f7f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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. diff --git a/tests/hermes_cli/test_dashboard_auth_gate.py b/tests/hermes_cli/test_dashboard_auth_gate.py index 867114d6f2f..545fedb9220 100644 --- a/tests/hermes_cli/test_dashboard_auth_gate.py +++ b/tests/hermes_cli/test_dashboard_auth_gate.py @@ -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