VYPR
Critical severity9.4NVD Advisory· Published Jun 11, 2026· Updated Jun 11, 2026

CVE-2026-49973

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.

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

Patches

2
1126e541325d

Merge PR #3973 — v0.51.358 first-run password bootstrap hardening (#3964)

https://github.com/nesquena/hermes-webuinesquena-hermesJun 11, 2026via nvd-ref
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)
    
63e643e67797

Merge a5af0b83af669904c7e19c57a32a0627e961973b into c77c8312c801b845488f0a98120328be33b6f845

https://github.com/nesquena/hermes-webuiHinotobiJun 11, 2026via nvd-ref
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

News mentions

0

No linked articles in our index yet.