Release v0.51.308 — Release JX (#3765 — gate onboarding-complete like its siblings) (#3765)

* fix(security): gate /api/onboarding/complete on the local-network check (#3765)

Sibling-path gap surfaced by the #3758 release gate. /api/onboarding/oauth/start,
/setup, and /probe are gated by _onboarding_gate_allows(), but
/api/onboarding/complete was not — it called complete_onboarding() unconditionally
(persists onboarding_completed=True, which hides the first-run wizard). On a
passwordless public bind, an unauthenticated no-Origin POST passes generic CSRF
and could flip the wizard off.

Pre-existing (the endpoint was ungated before #3758 too; #3758 only refactored the
three already-gated siblings). Low severity — it toggles a UI flag, not credentials
or access — but the inconsistency is a real hole, so close it the same way as its
siblings.

- Gate /api/onboarding/complete with _onboarding_gate_allows() → 403 when denied.
- Regression tests: public client (no forwarded headers) → 403 + complete_onboarding
  NOT called; loopback client → 200; auth-enabled → 200.
- Mark the legacy _is_local_from_handler mirror in test_onboarding_network.py as a
  STALE pre-#3758 contract (it trusts unauthenticated XFF); the authoritative
  trust-matrix tests live in test_security_review_fixes.py. Migrating the mirror to
  delegate to the real helper is tracked as follow-up test debt, out of scope here.

* docs(changelog): stamp v0.51.308 — Release JX (#3765 onboarding-complete sibling-consistency gate)

---------

Co-authored-by: nesquena-hermes <[email protected]>
This commit is contained in:
nesquena-hermesandnesquena-hermes authored and GitHub committed 2026-06-06 20:42:23 -07:00
1 parent 70596e6993
commit b6431eb677
4 files changed
+71 -4

No files matched your search

+5
View File
@@ -3,6 +3,11 @@
## [Unreleased]
## [v0.51.308] — 2026-06-07 — Release JX (consistency — gate onboarding-complete like its siblings)
### Changed
- **`/api/onboarding/complete` now applies the same local-network gate as the other onboarding endpoints, for consistency.** `oauth/start`, `setup`, and `probe` already refused unauthenticated non-local requests; `complete` (which persists `onboarding_completed=true` to hide the first-run wizard) did not, so all four onboarding mutators now behave alike — `complete` returns 403 unless the request is local, auth is enabled, or `HERMES_WEBUI_ONBOARDING_OPEN=1`. This is a consistency tidy, **not** a security boundary: the same flag is still settable via `POST /api/settings` (one of ~25 settings keys broadly writable on a passwordless public bind), and the real protection for a public bind is to run with authentication or refuse the bind. Low impact regardless — the flag only toggles a UI wizard, not credentials or access. (#3765, surfaced by the #3758 release gate)
## [v0.51.307] — 2026-06-06 — Release JW (stage-a3 — onboarding forwarded-IP spoof fix + update-check CSRF hardening)
### Security
+6
View File
@@ -7830,6 +7830,12 @@ def handle_post(handler, parsed) -> bool:
return bad(handler, str(e), 500)
if parsed.path == "/api/onboarding/complete":
# Marking onboarding complete flips the first-run wizard off (persists
# onboarding_completed=True). Gate it on the same local-network check as
# the other onboarding mutators so an unauthenticated public client on a
# passwordless bind can't hide the first-run wizard. (#3765)
if not _onboarding_gate_allows(handler):
return bad(handler, "Onboarding is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403)
return j(handler, complete_onboarding())
if parsed.path == "/api/onboarding/probe":
+15 -4
View File
@@ -30,7 +30,18 @@ from tests._pytest_port import BASE
# Unit tests — directly test the IP-resolution + guard logic in routes.py
# without needing a live server. We replicate the logic to keep tests fast
# and independent of server startup.
# ---------------------------------------------------------------------------
#
# ⚠️ STALE CONTRACT (see #3765): this mirror encodes the PRE-#3758 model where an
# unauthenticated `X-Forwarded-For` / `X-Real-IP` was trusted to establish
# locality (`_xff or _xri or raw_ip`). As of #3758 (v0.51.307) the real gate
# `api.routes._onboarding_request_is_local()` IGNORES forwarded headers unless
# `HERMES_WEBUI_TRUST_FORWARDED_FOR=1`, and only loopback counts as local when an
# untrusted forwarded header is present. The authoritative trust-matrix tests now
# live in `tests/test_security_review_fixes.py`. This mirror + its
# `TestOnboardingIPLogic` cases are retained only as historical fast-path unit
# coverage of the IP-parsing shape; DO NOT treat them as the current security
# contract. (Migrating them to delegate to the real helper is tracked as
# follow-up test debt, intentionally out of scope for the #3765 hotfix.)
def _is_local_from_handler(
raw_ip: str,
@@ -40,9 +51,9 @@ def _is_local_from_handler(
open_env: bool = False,
) -> bool | str:
"""
Mirror of the onboarding IP check in api/routes.py.
Returns True if the request would be allowed, False if blocked,
or the error message string if blocked.
Mirror of the LEGACY (pre-#3758) onboarding IP check. See the stale-contract
note above — the live gate no longer trusts forwarded headers by default.
Returns True if the request would be allowed, False if blocked.
"""
import ipaddress
+45
View File
@@ -176,3 +176,48 @@ def test_onboarding_direct_loopback_without_forwarded_headers_is_local(monkeypat
handler_public = _Handler(client_ip="8.8.8.8", headers={})
assert routes._onboarding_request_is_local(handler_public) is False
def test_onboarding_complete_is_gated_against_public_clients(monkeypatch):
"""POST /api/onboarding/complete flips the first-run wizard off
(onboarding_completed=True). On a passwordless bind it must be gated on the
same local-network check as setup/oauth/probe, so an unauthenticated public
client can't hide the wizard. (#3765 — sibling-path gap left by #3758.)
"""
from api import routes
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setenv("HERMES_WEBUI_ONBOARDING_OPEN", "")
monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False)
monkeypatch.setattr("api.auth.is_auth_enabled", lambda: False)
called = {"n": 0}
def fake_complete():
called["n"] += 1
return {"completed": True}
monkeypatch.setattr(routes, "complete_onboarding", fake_complete)
# Public client (no forwarded headers) → 403, complete_onboarding NOT called
pub = _Handler(client_ip="8.8.8.8", body=b"{}", headers={"Content-Length": "2"})
routes.handle_post(pub, SimpleNamespace(path="/api/onboarding/complete", query=""))
assert pub.status == 403
assert called["n"] == 0
# Genuine loopback client → allowed
loop = _Handler(client_ip="127.0.0.1", body=b"{}", headers={"Content-Length": "2"})
routes.handle_post(loop, SimpleNamespace(path="/api/onboarding/complete", query=""))
assert loop.status == 200
assert called["n"] == 1
def test_onboarding_complete_allowed_when_auth_enabled(monkeypatch):
"""With auth configured, onboarding endpoints are reachable normally."""
from api import routes
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr("api.auth.is_auth_enabled", lambda: True)
monkeypatch.setattr(routes, "complete_onboarding", lambda: {"completed": True})
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