CVE-2026-49973
Description
Hermes WebUI before 0.51.358 allows unauthenticated remote attackers to hijack initial setup via the _set_password endpoint, locking out the intended operator.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Hermes WebUI before 0.51.358 allows unauthenticated remote attackers to hijack initial setup via the _set_password endpoint, locking out the intended operator.
Vulnerability
Hermes WebUI before version 0.51.358 contains an improper access control vulnerability in the POST /api/settings endpoint. When authentication is disabled, the endpoint accepts the _set_password parameter without requiring a valid session. No network origin restriction exists, allowing unauthenticated remote attackers to set the first password during the first-run setup window [1].
Exploitation
An unauthenticated attacker on any reachable network can send a POST request to /api/settings with the _set_password field set to an arbitrary password hash during the initial setup phase. The request is processed, saving the password, enabling authentication, and immediately issuing a session cookie to the attacker. The legitimate operator is then locked out [1][4].
Impact
Successful exploitation allows the attacker to gain full administrative control of the Hermes WebUI instance. The attacker obtains a valid session and can modify settings, while the intended operator is denied access until manual state reset is performed [1].
Mitigation
The vulnerability is fixed in version 0.51.358, released on 2026-06-11 [3]. The fix gates the first-password bootstrap action to local clients only, preserving the intended local-first startup mode. Operators who need remote setup can opt-in by setting the HERMES_WEBUI_ONBOARDING_OPEN=1 environment variable [1]. No other workarounds are available; users should upgrade immediately.
- [security] fix(settings): gate first password setup to local clients by Hinotoi-agent · Pull Request #3964 · nesquena/hermes-webui
- Release v0.51.358 · nesquena/hermes-webui
- Release LV — v0.51.358 (first-run password bootstrap hardening, #3964) by nesquena-hermes · Pull Request #3973 · nesquena/hermes-webui
AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: <0.51.358
Patches
21126e541325dMerge PR #3973 — v0.51.358 first-run password bootstrap hardening (#3964)
3 files changed · +156 −2
api/routes.py+18 −2 modified@@ -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 _sync_session_title_to_insights(session): "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
CHANGELOG.md+6 −0 modified@@ -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
tests/test_security_review_fixes.py+132 −0 modified@@ -18,6 +18,7 @@ def __init__(self, *, client_ip="8.8.8.8", headers=None, body=b"{}"): 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)
63e643e67797Merge a5af0b83af669904c7e19c57a32a0627e961973b into c77c8312c801b845488f0a98120328be33b6f845
2 files changed · +150 −2
api/routes.py+18 −2 modified@@ -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 _sync_session_title_to_insights(session): "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
tests/test_security_review_fixes.py+132 −0 modified@@ -18,6 +18,7 @@ def __init__(self, *, client_ip="8.8.8.8", headers=None, body=b"{}"): 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)
Vulnerability mechanics
Root cause
"Missing locality gate on first-password bootstrap in POST /api/settings allows any unauthenticated network client to set the initial password and obtain a session cookie."
Attack vector
An unauthenticated attacker on any network reachable to a fresh Hermes WebUI instance (one started without a password configured) can send a POST request to `/api/settings` with the `_set_password` parameter. Because authentication is disabled, the endpoint accepts the request without a session, persists the attacker's password hash, and issues a session cookie to the attacker. This locks out the legitimate operator until the instance state is manually reset [ref_id=1]. The patch notes that the vulnerable condition is not "change someone else's existing password" but "be the first requester to set the initial password on a reachable no-password instance" [ref_id=1].
Affected code
The vulnerability exists in `api/routes.py` in the `POST /api/settings` handler. When authentication is disabled, the handler accepted the `_set_password` parameter without applying the existing onboarding locality gate, allowing any reachable client to set the initial password and obtain a session cookie. The patch adds a locality check via `_onboarding_gate_allows()` before persisting the first password [patch_id=5642277].
What the fix does
The patch adds a guard in `api/routes.py` that checks `_onboarding_gate_allows(handler, auth_enabled_before)` before processing a `_set_password` request when authentication was disabled at the start of the request [patch_id=5642277]. This reuses the existing onboarding locality check, which permits only loopback/private network clients by default, or any client when the operator explicitly sets `HERMES_WEBUI_ONBOARDING_OPEN=1`. The auth state is captured at request start to prevent a TOCTOU race where authentication could be enabled mid-request [ref_id=1]. Normal authenticated password changes after auth is enabled are unaffected.
Preconditions
- configHermes WebUI must be started without a password configured (no HERMES_WEBUI_PASSWORD env var) and with a fresh settings state.
- networkThe instance must be reachable by the attacker over the network (LAN, tunnel, reverse proxy, container bind, or accidental public bind).
- authNo authentication session is required; the attacker sends an unauthenticated POST request.
- inputThe request body must include the _set_password parameter with an arbitrary password value.
Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/nesquena/hermes-webui/commit/1126e541325d401538f6a272a9c024c37d47ae08nvd
- github.com/nesquena/hermes-webui/pull/3964nvd
- github.com/nesquena/hermes-webui/pull/3973nvd
- github.com/nesquena/hermes-webui/releases/tag/v0.51.358nvd
- www.vulncheck.com/advisories/hermes-webui-unauthenticated-password-takeover-via-api-settingsnvd
News mentions
0No linked articles in our index yet.