import io from types import SimpleNamespace from urllib.parse import urlsplit from pathlib import Path class _Headers(dict): def get(self, key, default=None): for k, v in self.items(): if k.lower() == key.lower(): return v return default class _Handler: def __init__(self, *, client_ip="8.8.8.8", headers=None, body=b"{}"): self.client_address = (client_ip, 12345) self.headers = _Headers(headers or {}) self.rfile = io.BytesIO(body) self.wfile = io.BytesIO() self.request = None self.status = None self.sent_headers = [] def send_response(self, code): self.status = code def send_header(self, key, value): self.sent_headers.append((key, value)) def end_headers(self): pass def test_onboarding_local_gate_ignores_forwarded_ip_unless_trusted(monkeypatch): from api import routes monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) handler = _Handler( client_ip="8.8.8.8", headers={"X-Forwarded-For": "127.0.0.1", "X-Real-IP": "10.0.0.2"}, ) assert routes._onboarding_request_is_local(handler) is False def test_onboarding_local_gate_uses_forwarded_ip_when_explicitly_trusted(monkeypatch): """Even with HERMES_WEBUI_TRUST_FORWARDED_FOR=1, a forwarded header is only honored when the RAW socket peer is a trusted proxy (loopback or in HERMES_WEBUI_TRUSTED_PROXY_CIDRS). A PUBLIC direct client (raw peer 8.8.8.8) that merely SETS X-Forwarded-For can NOT promote itself to local — otherwise a passwordless WebUI with the opt-in enabled would admit any remote attacker to the embedded terminal (#5764). The forwarded IP is consulted only after the un-spoofable socket peer is confirmed to be a trusted proxy. """ from api import routes monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", "1") monkeypatch.delenv("HERMES_WEBUI_TRUSTED_PROXY_CIDRS", raising=False) handler = _Handler( client_ip="8.8.8.8", headers={"X-Forwarded-For": "10.0.0.2", "X-Real-IP": "203.0.113.11"}, ) # Raw peer 8.8.8.8 is NOT a trusted proxy → forwarded header ignored → # classified by the public raw peer → DENY. assert routes._onboarding_request_is_local(handler) is False # With the peer's own network in the trusted-proxy allowlist, the forwarded # client IP (private 10.0.0.2) is now honored → local. monkeypatch.setenv("HERMES_WEBUI_TRUSTED_PROXY_CIDRS", "8.8.8.0/24") assert routes._onboarding_request_is_local(handler) is True def test_onboarding_trusted_forwarded_for_uses_proxy_appended_rightmost_ip(monkeypatch): from api import routes monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", "1") handler = _Handler( client_ip="10.0.0.10", headers={"X-Forwarded-For": "127.0.0.1, 8.8.8.8"}, ) assert routes._onboarding_request_is_local(handler) is False def test_docker_env_log_obfuscates_password_and_secret_names(): src = Path("docker_init.bash").read_text(encoding="utf-8") line = next(l for l in src.splitlines() if l.startswith("export ENV_OBFUSCATE_PART=")) assert "PASSWORD" in line assert "SECRET" in line assert "TOKEN" in line assert "API" in line assert "KEY" in line def test_get_update_check_returns_cache_without_fetch(monkeypatch): from api import routes, updates monkeypatch.setattr(routes, "load_settings", lambda: {"check_for_updates": True}) monkeypatch.setattr(updates, "cached_update_status", lambda include_agent=True: {"checked_at": 123, "webui": None, "agent": None, "include_agent": include_agent}) monkeypatch.setattr(updates, "check_for_updates", lambda *a, **k: (_ for _ in ()).throw(AssertionError("GET must not fetch"))) handler = _Handler(client_ip="127.0.0.1") routes.handle_get(handler, urlsplit("/api/updates/check?force=1")) assert handler.status == 200 def test_cached_update_status_does_not_drop_agent_info_when_reenabled(monkeypatch): from api import updates cached_agent = {"name": "agent", "behind": 2} monkeypatch.setattr( updates, "_update_cache", { "webui": {"name": "webui", "behind": 0}, "agent": cached_agent, "checked_at": 123, "include_agent": False, }, ) result = updates.cached_update_status(include_agent=True) assert result["agent"] == cached_agent def test_post_update_check_performs_forced_fetch(monkeypatch): from api import routes calls = [] monkeypatch.setattr(routes, "load_settings", lambda: {"check_for_updates": True}) monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) def fake_check(*, force=False, include_agent=True, channel="stable"): calls.append((force, include_agent)) return {"checked_at": 456, "webui": None, "agent": None} monkeypatch.setattr("api.updates.check_for_updates", fake_check) body = b'{"force": true}' handler = _Handler(client_ip="127.0.0.1", body=body, headers={"Content-Length": str(len(body))}) routes.handle_post(handler, SimpleNamespace(path="/api/updates/check", query="")) assert handler.status == 200 assert calls == [(True, True)] def test_onboarding_untrusted_forwarded_header_denies_lan_proxy_socket(monkeypatch): """Reverse-proxy regression (release-gate CORE fix): when forwarded headers are present but HERMES_WEBUI_TRUST_FORWARDED_FOR is NOT set, the spoofable header is ignored and locality is judged by the raw socket — but a PRIVATE/LAN raw socket (a separate proxy box that could be forwarding an arbitrary public client) is NOT treated as local. A loopback raw socket is still genuine same-host and remains allowed (a remote attacker cannot forge a 127.0.0.1 TCP source). Operators with a LAN proxy must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1. """ from api import routes monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) # LAN proxy box (private raw socket) forwarding a public client → DENY handler = _Handler(client_ip="10.0.0.5", headers={"X-Real-IP": "203.0.113.7"}) assert routes._onboarding_request_is_local(handler) is False handler2 = _Handler(client_ip="172.20.0.1", headers={"X-Forwarded-For": "8.8.8.8"}) assert routes._onboarding_request_is_local(handler2) is False # Genuine same-host: loopback raw socket is local even if a forwarded header # is present (the TCP source genuinely came from localhost; unspoofable). handler3 = _Handler(client_ip="127.0.0.1", headers={"X-Forwarded-For": "8.8.8.8"}) assert routes._onboarding_request_is_local(handler3) is True def test_onboarding_spoofed_forwarded_header_from_public_socket_denied(monkeypatch): """The original spoof hole: a public client setting X-Forwarded-For=127.0.0.1 must NOT bypass the gate. The forwarded header is ignored; the public raw socket governs → denied. """ from api import routes monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) handler = _Handler(client_ip="8.8.8.8", headers={"X-Forwarded-For": "127.0.0.1"}) assert routes._onboarding_request_is_local(handler) is False def test_onboarding_direct_loopback_without_forwarded_headers_is_local(monkeypatch): """A genuine direct local client (no proxy headers) is still allowed.""" from api import routes monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) handler = _Handler(client_ip="127.0.0.1", headers={}) assert routes._onboarding_request_is_local(handler) is True handler_public = _Handler(client_ip="8.8.8.8", headers={}) assert routes._onboarding_request_is_local(handler_public) is False def test_onboarding_complete_is_gated_against_public_clients(monkeypatch): """POST /api/onboarding/complete flips the first-run wizard off (onboarding_completed=True). On a passwordless bind it must be gated on the same local-network check as setup/oauth/probe, so an unauthenticated public client can't hide the wizard. (#3765 — sibling-path gap left by #3758.) """ from api import routes monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setenv("HERMES_WEBUI_ONBOARDING_OPEN", "") monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) monkeypatch.setattr("api.auth.is_auth_enabled", lambda: False) called = {"n": 0} def fake_complete(): called["n"] += 1 return {"completed": True} monkeypatch.setattr(routes, "complete_onboarding", fake_complete) # Public client (no forwarded headers) → 403, complete_onboarding NOT called pub = _Handler(client_ip="8.8.8.8", body=b"{}", headers={"Content-Length": "2"}) routes.handle_post(pub, SimpleNamespace(path="/api/onboarding/complete", query="")) assert pub.status == 403 assert called["n"] == 0 # Genuine loopback client → allowed loop = _Handler(client_ip="127.0.0.1", body=b"{}", headers={"Content-Length": "2"}) routes.handle_post(loop, SimpleNamespace(path="/api/onboarding/complete", query="")) assert loop.status == 200 assert called["n"] == 1 def test_onboarding_complete_allowed_when_auth_enabled(monkeypatch): """With auth configured, onboarding endpoints are reachable normally.""" from api import routes monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr("api.auth.is_auth_enabled", lambda: True) monkeypatch.setattr(routes, "complete_onboarding", lambda: {"completed": True}) h = _Handler(client_ip="8.8.8.8", body=b"{}", headers={"Content-Length": "2"}) routes.handle_post(h, SimpleNamespace(path="/api/onboarding/complete", query="")) assert h.status == 200 def test_first_password_setup_is_gated_against_public_clients(monkeypatch): """Unauthenticated first-password setup is bootstrap-sensitive. While auth is disabled, POST /api/settings normally passes the auth/CSRF checks. A public client must not be able to win first-run ownership by setting `_set_password`; it should be gated like onboarding setup and should not write settings. """ from api import routes monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr("api.auth.is_auth_enabled", lambda: False) monkeypatch.setattr("api.auth.parse_cookie", lambda handler: "") monkeypatch.setattr("api.auth.verify_session", lambda cookie: False) monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False) monkeypatch.delenv("HERMES_WEBUI_ONBOARDING_OPEN", raising=False) monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) saved = {"called": False} monkeypatch.setattr( routes, "save_settings", lambda body: saved.__setitem__("called", True) or dict(body), ) body = b'{"_set_password":"attacker-password"}' handler = _Handler( client_ip="8.8.8.8", body=body, headers={"Content-Length": str(len(body))}, ) routes.handle_post(handler, SimpleNamespace(path="/api/settings", query="")) assert handler.status == 403 assert saved["called"] is False def test_first_password_setup_allows_genuine_loopback_client(monkeypatch): """A same-host first-run setup flow still works without setting the bypass env.""" from api import routes auth_state = {"enabled": False} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr("api.auth.is_auth_enabled", lambda: auth_state["enabled"]) monkeypatch.setattr("api.auth.parse_cookie", lambda handler: "") monkeypatch.setattr("api.auth.verify_session", lambda cookie: False) monkeypatch.setattr("api.auth.create_session", lambda: "new-session") monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False) monkeypatch.delenv("HERMES_WEBUI_ONBOARDING_OPEN", raising=False) monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) def fake_save_settings(body): auth_state["enabled"] = True return {"theme": "dark", "password_hash": "redacted"} monkeypatch.setattr(routes, "save_settings", fake_save_settings) body = b'{"_set_password":"local-owner-password"}' handler = _Handler( client_ip="127.0.0.1", body=body, headers={"Content-Length": str(len(body))}, ) routes.handle_post(handler, SimpleNamespace(path="/api/settings", query="")) assert handler.status == 200 assert any(key.lower() == "set-cookie" for key, _ in handler.sent_headers) def test_first_password_setup_uses_initial_auth_state_for_gate(monkeypatch): """A public bootstrap request cannot pass just because auth flips mid-request.""" from api import routes auth_checks = iter([False, True]) monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr("api.auth.is_auth_enabled", lambda: next(auth_checks)) monkeypatch.setattr("api.auth.parse_cookie", lambda handler: "") monkeypatch.setattr("api.auth.verify_session", lambda cookie: False) monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False) monkeypatch.delenv("HERMES_WEBUI_ONBOARDING_OPEN", raising=False) saved = {"called": False} monkeypatch.setattr( routes, "save_settings", lambda body: saved.__setitem__("called", True) or dict(body), ) body = b'{"_set_password":"attacker-password"}' handler = _Handler( client_ip="8.8.8.8", body=body, headers={"Content-Length": str(len(body))}, ) routes.handle_post(handler, SimpleNamespace(path="/api/settings", query="")) assert handler.status == 403 assert saved["called"] is False def test_first_password_setup_allows_public_client_with_open_onboarding(monkeypatch): """The documented operator opt-in permits remote first-run bootstrap.""" from api import routes auth_state = {"enabled": False} monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr("api.auth.is_auth_enabled", lambda: auth_state["enabled"]) monkeypatch.setattr("api.auth.parse_cookie", lambda handler: "") monkeypatch.setattr("api.auth.verify_session", lambda cookie: False) monkeypatch.setattr("api.auth.create_session", lambda: "new-session") monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False) monkeypatch.setenv("HERMES_WEBUI_ONBOARDING_OPEN", "1") def fake_save_settings(body): auth_state["enabled"] = True return {"theme": "dark", "password_hash": "redacted"} monkeypatch.setattr(routes, "save_settings", fake_save_settings) body = b'{"_set_password":"remote-owner-password"}' handler = _Handler( client_ip="8.8.8.8", body=body, headers={"Content-Length": str(len(body))}, ) routes.handle_post(handler, SimpleNamespace(path="/api/settings", query="")) assert handler.status == 200 assert any(key.lower() == "set-cookie" for key, _ in handler.sent_headers)