diff --git a/CHANGELOG.md b/CHANGELOG.md index 64dd43985..2ee6d4155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - **New RFC: Stable Assistant Turn Anchors for Live-to-Final rendering.** Defines a frontend presentation/reconciliation model for anchoring one assistant turn across live streaming, settlement, replay/reload/recovery, Compact Worklog, Transparent Stream, terminal states, artifacts, and side effects. (#3926) +## [v0.51.358] — 2026-06-11 — Release LV (first-run password bootstrap hardening) + +### Security + +- **First-run password setup is now gated to local clients.** While auth is disabled, `POST /api/settings` is intentionally reachable without a session for local first-run setup — but setting `_set_password` establishes WebUI ownership and issues a session. A non-local unauthenticated client could previously win first-run ownership by posting `_set_password`. First-password bootstrap is now gated the same way as onboarding setup (loopback/private networks only, or an explicit `HERMES_WEBUI_ONBOARDING_OPEN=1` opt-in for remote bootstrap), evaluated against the auth state captured at the start of the request. Normal authenticated password changes after auth is enabled are unaffected. (#3964) + ## [v0.51.357] — 2026-06-11 — Release LU (mid-stream flicker tie fix) ### Fixed diff --git a/api/routes.py b/api/routes.py index f7833569d..6b6ddcbb9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1849,10 +1849,11 @@ def _onboarding_request_is_local(handler) -> bool: return bool(addr.is_loopback or addr.is_private) -def _onboarding_gate_allows(handler) -> bool: +def _onboarding_gate_allows(handler, auth_enabled: bool | None = None) -> bool: from api.auth import is_auth_enabled - if is_auth_enabled() or _truthy_env("HERMES_WEBUI_ONBOARDING_OPEN"): + auth_enabled = is_auth_enabled() if auth_enabled is None else auth_enabled + if auth_enabled or _truthy_env("HERMES_WEBUI_ONBOARDING_OPEN"): return True return _onboarding_request_is_local(handler) @@ -8322,6 +8323,21 @@ def handle_post(handler, parsed) -> bool: "Unset the env var and restart the server before changing the password here.", 409, ) + + # First password creation decides who owns a previously passwordless + # WebUI. While auth is disabled, the generic /api/settings route is also + # unauthenticated, so gate bootstrap password setup the same way as + # onboarding setup: local/private networks only, unless the operator + # explicitly opts into remote bootstrap with HERMES_WEBUI_ONBOARDING_OPEN. + if requested_password and not auth_enabled_before: + if not _onboarding_gate_allows(handler, auth_enabled_before): + return bad( + handler, + "First password setup is only available from local networks when auth is not enabled. " + "To bootstrap this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", + 403, + ) + if requested_passwordless: from api.auth import _passkey_feature_flag_enabled from api.passkeys import registered_credentials diff --git a/tests/test_security_review_fixes.py b/tests/test_security_review_fixes.py index 0ac189e80..d33d57c80 100644 --- a/tests/test_security_review_fixes.py +++ b/tests/test_security_review_fixes.py @@ -18,6 +18,7 @@ class _Handler: self.headers = _Headers(headers or {}) self.rfile = io.BytesIO(body) self.wfile = io.BytesIO() + self.request = None self.status = None self.sent_headers = [] @@ -221,3 +222,134 @@ def test_onboarding_complete_allowed_when_auth_enabled(monkeypatch): 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)