fix: harden WebUI security-sensitive defaults - #3758
fantasticsquirrel wants to merge 2 commits into
Conversation
|
| Filename | Overview |
|---|---|
| api/routes.py | Adds _onboarding_gate_allows / _onboarding_request_is_local helpers replacing duplicated inline checks; moves update check to cached_update_status on GET; adds POST handler for force-refresh; removes /tmp from media allowed roots. Docstring for _handle_media still references /tmp as an allowed root after removal. |
| api/updates.py | Adds cached_update_status() for cache-only GET reads. Logic correctly suppresses agent info when include_agent=False and preserves existing cached agent data when re-enabling; but test fixture for the re-enable case uses a state unreachable by normal code flow. |
| server.py | Adds _public_bind_requires_auth() helper and wires it to sys.exit(1) on public passwordless bind in Docker. Logic is correct: loopback hosts bypass, env var opt-out honored, within_container is the final fallback. |
| tests/test_security_review_fixes.py | New regression suite covering all PR contracts. The test_cached_update_status_does_not_drop_agent_info_when_reenabled fixture is unrealistic — it stores real agent update data (behind: 2) under an include_agent: False cache, a state that check_for_updates(include_agent=False) never produces. |
| docker_init.bash | Extends ENV_OBFUSCATE_PART to mask PASSWORD, SECRET, CREDENTIAL, COOKIE, and SESSION variable names in addition to the existing TOKEN, API, KEY. |
| Dockerfile | Sets HERMES_WEBUI_REQUIRE_AUTH_FOR_PUBLIC_BIND=1 so the new startup guard in server.py fires for all Docker images by default. |
| static/boot.js | Switches on-boot update check from GET to POST with {force:false}; test-mode simulation path keeps GET with ?simulate=1. |
| static/panels.js | Switches the manual Check for updates button from GET ?force=1 to POST {force: true}, matching the new endpoint contract. |
| tests/test_media_inline.py | Updated to expect 403 for bare /tmp media requests (PNG, audio, HTML); test_nonexistent_file_returns_404 now accepts 403 as well since the path check fires before the existence check. |
Sequence Diagram
sequenceDiagram
participant Browser
participant GET as GET /api/updates/check
participant POST as POST /api/updates/check
participant Cache as cached_update_status()
participant Full as check_for_updates()
participant Git as git fetch
Note over Browser,Git: Normal page boot (boot.js)
Browser->>POST: "POST {force: false}"
POST->>Full: "force=False"
alt cache fresh (TTL not expired)
Full-->>POST: cached dict
else cache stale
Full->>Git: git fetch origin --tags
Git-->>Full: tag refs
Full-->>POST: updated dict
end
POST-->>Browser: "{webui, agent, checked_at}"
Note over Browser,Git: Subsequent polls (boot.js sessionStorage guard)
Browser->>GET: GET /api/updates/check
GET->>Cache: "include_agent=True/False"
Cache-->>GET: "shallow copy + cached=True"
GET-->>Browser: "{webui, agent, cached: true}"
Note over Browser,Git: Manual Check for updates button (panels.js)
Browser->>POST: "POST {force: true}"
POST->>Full: "force=True"
Full->>Git: git fetch origin --tags (forced)
Git-->>Full: tag refs
Full-->>POST: updated dict
POST-->>Browser: "{webui, agent, checked_at}"
Reviews (2): Last reviewed commit: "fix: address security review follow-ups" | Re-trigger Greptile
|
Addressed both Greptile findings in follow-up commit 54c69a4. Changes:
Regression tests added:
Verification:
|
…ate-check CSRF, #3758 partial) (#3764) * fix(security): ignore spoofable forwarded IPs in onboarding gate + make update-check CSRF-safe (#3758, partial) Ships the two unambiguous slices of #3758's security review. The two slices with breakage risk for existing installs — the Docker-default public-bind-requires-auth gate and removing /tmp from the /api/media allowed roots — are held for separate review/decision. Onboarding forwarded-IP spoof hardening (+ release-gate CORE fix): - The unauthenticated first-run onboarding local-network gate now IGNORES X-Forwarded-For / X-Real-IP by default (a direct client can spoof them to a private/loopback address to bypass the gate), trusting them only when HERMES_WEBUI_TRUST_FORWARDED_FOR=1 is set behind a trusted proxy (rightmost proxy-appended hop). - Release-gate (Codex) CORE catch + refinement: when forwarded headers are present but untrusted, the header is ignored and locality is judged by the raw socket — but a PRIVATE/LAN raw socket (a separate proxy box that could forward an arbitrary public client) is no longer treated as local; only a LOOPBACK raw socket is (genuine same-host; a remote attacker can't forge a 127.0.0.1 TCP source). This closes the new fail-open the initial refactor introduced (public client behind a LAN proxy read as local) while preserving genuine same-host onboarding. LAN-proxy operators must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1. Regression tests lock the full matrix (spoof-block, LAN-proxy-deny, loopback-allow, trusted-proxy-rightmost-hop, direct-public-deny). - Three duplicated inline gate blocks unified into _onboarding_gate_allows / _onboarding_request_is_local; ONBOARDING_OPEN normalized to canonical truthy values via _truthy_env. Update-check CSRF hardening: - GET /api/updates/check is cache-only (cached_update_status(): no network/git mutation); forced refresh moves to POST /api/updates/check {force:true}; both frontend call sites updated and the test_api_timeout contract assertion updated. - cached_update_status() preserves cached agent info when include_agent re-enabled. Docker log masking: ENV_OBFUSCATE_PART also masks PASSWORD/SECRET/CREDENTIAL/COOKIE/SESSION. Held for separate review (NOT in this PR): public-bind-requires-auth startup gate (server.py + Dockerfile default) and the /api/media /tmp-root removal. Co-authored-by: fantasticsquirrel <[email protected]> * docs(changelog): stamp v0.51.307 — Release JW (stage-a3 #3758 partial) --------- Co-authored-by: nesquena-hermes <[email protected]>
… 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]>
|
Pulled the branch ( What I verified1. Onboarding gate refactor is the real win. The three onboarding POST handlers ( candidates = []
if _truthy_env("HERMES_WEBUI_TRUST_FORWARDED_FOR"):
candidates.extend([
handler.headers.get("X-Forwarded-For", "").split(",")[-1].strip(),
handler.headers.get("X-Real-IP", "").strip(),
])
candidates.append(_request_client_ip(handler))The loop returns on the first parseable candidate and falls through to 2. GET/POST update-check split is correctly CSRF-protected. 3. Public-bind guard. 4. One design note (non-blocking)The Test planPer the execution ban I didn't run anything from the worktree. |
…ate-check CSRF, nesquena#3758 partial) (nesquena#3764) * fix(security): ignore spoofable forwarded IPs in onboarding gate + make update-check CSRF-safe (nesquena#3758, partial) Ships the two unambiguous slices of nesquena#3758's security review. The two slices with breakage risk for existing installs — the Docker-default public-bind-requires-auth gate and removing /tmp from the /api/media allowed roots — are held for separate review/decision. Onboarding forwarded-IP spoof hardening (+ release-gate CORE fix): - The unauthenticated first-run onboarding local-network gate now IGNORES X-Forwarded-For / X-Real-IP by default (a direct client can spoof them to a private/loopback address to bypass the gate), trusting them only when HERMES_WEBUI_TRUST_FORWARDED_FOR=1 is set behind a trusted proxy (rightmost proxy-appended hop). - Release-gate (Codex) CORE catch + refinement: when forwarded headers are present but untrusted, the header is ignored and locality is judged by the raw socket — but a PRIVATE/LAN raw socket (a separate proxy box that could forward an arbitrary public client) is no longer treated as local; only a LOOPBACK raw socket is (genuine same-host; a remote attacker can't forge a 127.0.0.1 TCP source). This closes the new fail-open the initial refactor introduced (public client behind a LAN proxy read as local) while preserving genuine same-host onboarding. LAN-proxy operators must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1. Regression tests lock the full matrix (spoof-block, LAN-proxy-deny, loopback-allow, trusted-proxy-rightmost-hop, direct-public-deny). - Three duplicated inline gate blocks unified into _onboarding_gate_allows / _onboarding_request_is_local; ONBOARDING_OPEN normalized to canonical truthy values via _truthy_env. Update-check CSRF hardening: - GET /api/updates/check is cache-only (cached_update_status(): no network/git mutation); forced refresh moves to POST /api/updates/check {force:true}; both frontend call sites updated and the test_api_timeout contract assertion updated. - cached_update_status() preserves cached agent info when include_agent re-enabled. Docker log masking: ENV_OBFUSCATE_PART also masks PASSWORD/SECRET/CREDENTIAL/COOKIE/SESSION. Held for separate review (NOT in this PR): public-bind-requires-auth startup gate (server.py + Dockerfile default) and the /api/media /tmp-root removal. Co-authored-by: fantasticsquirrel <[email protected]> * docs(changelog): stamp v0.51.307 — Release JW (stage-a3 nesquena#3758 partial) --------- Co-authored-by: nesquena-hermes <[email protected]>
…ete like its siblings) (nesquena#3765) * fix(security): gate /api/onboarding/complete on the local-network check (nesquena#3765) Sibling-path gap surfaced by the nesquena#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 nesquena#3758 too; nesquena#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-nesquena#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 (nesquena#3765 onboarding-complete sibling-consistency gate) --------- Co-authored-by: nesquena-hermes <[email protected]>
|
Thanks for this — it was a genuinely clean, well-tested security PR, and it's having real impact even though we're closing the PR itself (it went stale + conflicting after the review). Status of the four hardenings:
Closing the PR as superseded/absorbed — three parts already in master, the public-bind guard landing now with credit, and the |
Thinking Path
A security review found several high-risk default/path trust issues around first-run onboarding, Docker exposure, update checks, and media file serving. This PR keeps the changes scoped to those reviewed surfaces and adds regression coverage for the new security contracts.
What Changed
X-Forwarded-For/X-Real-IPfor unauthenticated onboarding local-network checks unlessHERMES_WEBUI_TRUST_FORWARDED_FOR=1is explicitly set.HERMES_WEBUI_REQUIRE_AUTH_FOR_PUBLIC_BIND=1, and startup refuses non-loopback passwordless binds unless explicitly disabled.GET /api/updates/checkcache-only and move git/network refreshes toPOST /api/updates/check./tmpfrom default/api/mediaallowed roots; temporary files now require sessionMEDIA:grants or explicitMEDIA_ALLOWED_ROOTS.Why It Matters
0.0.0.0bind from exposing a passwordless WebUI by default.git fetchside effects./tmpmedia reads.Verification
pytest tests/test_security_review_fixes.py tests/test_update_checker.py tests/test_update_check_ui.py tests/test_api_timeout.py -qpytest tests/test_media_inline.py tests/test_security_review_fixes.py tests/test_update_checker.py tests/test_update_check_ui.py tests/test_api_timeout.py tests/test_v050260_docker_invariants.py tests/test_issue2453_agent_source_boundary.py -qpython3 -m py_compile api/routes.py api/updates.py server.pyRisks / Follow-ups
/tmpmedia URLs are now denied unless represented by a session media grant or explicitMEDIA_ALLOWED_ROOTS. This is intentionally stricter and may require operators with custom scratch directories to configure an explicit allow-list.Contract Routing
Task type: security hardening
Touched areas: onboarding gates, Docker startup defaults/logging, update checks, media serving
Relevant public docs:
AGENTS.mdCONTRIBUTING.mddocs/CONTRACTS.mdCHANGELOG.mdScope boundaries: no UI layout changes, no installer pinning, no full dependency policy rewrite
Evidence needed before claiming done: targeted regression tests plus Python compile checks
Model Used
AI-assisted: OpenAI gpt-5.5 via Hermes Agent/Codex tool workflow.