mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-20 06:30:27 +00:00
Release LV stage — v0.51.358 (#3964 first-run password bootstrap local gate)
#3964 [security] gate first-password bootstrap (_set_password on POST /api/settings while auth disabled) to local clients — blocks remote unauth first-run ownership. Uses request-start auth snapshot (auth_enabled_before), so no mid-request TOCTOU. Self-rebased onto v0.51.357 (8-behind, 3-dot fidelity verified byte-identical). #3970 (oauth single-flight) DROPPED from this stage: Codex+Opus both caught a check-then-insert race — _pending_oauth_flow_for releases the lock before the device-code request + flow insertion, so concurrent unauth starts still spawn multiple workers (Codex empirically reproduced w/ 2 threads). Returned to author w/ the atomic per-(provider,home) start-lock fix. Re-gating the (N-1) stage. Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+18
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user