Root cause
Reachable window
While no password is configured the request check admits every client and the settings endpoint is deliberately reachable, so a remote first caller could claim ownership of the instance.
How AI contributed: Direct introductionPublished Jun 11, 2026 in
nesquena/hermes-webuiAll 6 findings in this repository
This WebUI ships with optional password auth switched off, so every request is allowed through until an operator sets a password. The same change that added passwords taught the already generic settings endpoint to read a private _set_password field, hash the value and persist it as password_hash, with no check on where the request came from. Anyone able to reach the port during the first-run window could therefore install their own password, log in with it, and leave the intended operator outside their own instance. The fix captures the auth state at the start of the request and refuses a first-password write from a non-local client while auth is still disabled; local and private-network clients still work, and an operator can opt in to remote bootstrap on purpose.
On a fresh instance no password is configured, so the request check admits every client by design. The generic settings endpoint still honoured a private _set_password field: it hashed the caller's value into settings.json as password_hash, which switches authentication on, and the caller could then sign in with the password they had chosen. The repair refuses first-password setup from a non-local client while auth is disabled, unless the operator deliberately opens remote bootstrap.
AI-assisted change
The settings writer was taught to take an unauthenticated _set_password value as the instance credential, hashing it into settings.json and switching authentication on with a password the caller chose.
Root cause
While no password is configured the request check admits every client and the settings endpoint is deliberately reachable, so a remote first caller could claim ownership of the instance.
Fix
First-password setup is now refused for a non-local client while auth is still disabled, judged against the auth state captured at the start of the request, while local setup and an explicit operator opt-in keep working.
Case facts
@@ -595,6 +595,7 @@ _SETTINGS_DEFAULTS = { 'default_model': DEFAULT_MODEL, 'default_workspace': str(DEFAULT_WORKSPACE), 'send_key': 'enter', # 'enter' or 'ctrl+enter'+ 'password_hash': None, # SHA-256 hash; None = auth disabled } def load_settings() -> dict:@@ -616,7 +617,13 @@ _SETTINGS_ENUM_VALUES = { def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys."""+ import hashlib as _hl current = load_settings()+ # Handle _set_password: hash and store as password_hash+ raw_pw = settings.pop('_set_password', None)+ if raw_pw and isinstance(raw_pw, str) and raw_pw.strip():+ salt = str(STATE_DIR).encode()+ current['password_hash'] = _hl.sha256(salt + raw_pw.strip().encode()).hexdigest() for k, v in settings.items(): if k in _SETTINGS_ALLOWED_KEYS: # Validate enum-constrained keys@@ -0,0 +1,149 @@+def check_auth(handler, parsed):+ """Check if request is authorized. Returns True if OK.+ If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""+ if not is_auth_enabled():+ return True+ # Public paths don't require auth+ if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/'):+ return True+ # Check session cookie+ cookie_val = parse_cookie(handler)+ if cookie_val and verify_session(cookie_val):+ return True+ # Not authorized+ if parsed.path.startswith('/api/'):+ handler.send_response(401)+ handler.send_header('Content-Type', 'application/json')+ handler.end_headers()+ handler.wfile.write(b'{"error":"Authentication required"}')+ else:+ handler.send_response(302)+ handler.send_header('Location', '/login')+ handler.end_headers()+ return False@@ -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_credentialsAI-assisted change e03701ce05c59260f72e0870f43aea235a9c3011183277b42bf054a9d94b1e3a · Fix 3decb1624561d9a0600a9fadab228610d86b18a41b7437aefb8a7cb7182c6fe0