Skip to content

fix(security): fail-closed when WebSocket peer is empty in loopback mode (#15544) - #50389

Merged
teknium1 merged 1 commit into
mainfrom
salvage/15544-ws-empty-peer
Jun 21, 2026
Merged

fix(security): fail-closed when WebSocket peer is empty in loopback mode (#15544)#50389
teknium1 merged 1 commit into
mainfrom
salvage/15544-ws-empty-peer

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

A WebSocket with no identifiable peer (ws.client is None or host == "") is now rejected on a loopback-bound, auth-disabled dashboard instead of being default-allowed.

Root cause: both _ws_client_reason and _ws_client_is_allowed returned "allowed" (None / True) when client_host was empty. On a loopback bind whose only auth is the legacy ?token= query param, an ASGI server behind a misconfigured reverse proxy or a unix-socket transport can deliver an empty/None client, letting an unidentified peer through — a fail-open default-allow.

Changes

  • hermes_cli/web_server.py: empty client_host now fails closed — _ws_client_reason returns a missing_or_empty_peer reason and _ws_client_is_allowed returns False. Scoped to the loopback path only; the explicit non-loopback --insecure bind and the OAuth-gated path early-return before this check, so public/LAN/gated peers are unaffected.
  • tests/hermes_cli/test_dashboard_auth_ws_auth.py: 5 regression tests covering empty host rejected, None-client rejected, the block reason, and still-allowed under both --insecure and gated modes.

Validation

Before After
empty/None peer on loopback bind allowed (fail-open) rejected (fail-closed)
--insecure non-loopback bind allowed allowed (unchanged)
OAuth-gated bind allowed allowed (unchanged)
tests/hermes_cli/test_dashboard_auth_ws_auth.py 50/50 pass

Salvaged from #15544 by @memosr — commit cherry-picked onto current main with authorship preserved.

Infographic

websocket-empty-peer-fail-closed

Per @egilewski's audit on this PR (#15544), the original fix was
correct but the file has refactored since: the four endpoint-local
empty-peer checks have been consolidated into _ws_client_is_allowed
and _ws_client_reason, but the helpers were left fail-open ('no peer
host known means allow' / 'no reason to block').

On a loopback-bound dashboard with auth disabled, an ASGI server
behind a misconfigured proxy or a unix-socket transport can deliver
ws.client == None or ws.client.host == ''. The helpers were treating
that as 'allowed', so the loopback-only peer gate could be bypassed
by anything that suppressed the client tuple in transit. All four
WebSocket endpoints (/api/pty, /api/ws, /api/pub, /api/events) route
through _ws_request_is_allowed -> _ws_client_is_allowed, so the gap
applied uniformly.

Fix:

* _ws_client_is_allowed: return False when client_host is empty
  instead of True. Only reached on loopback bind with auth disabled
  (auth_required=True and explicit non-loopback binds short-circuit
  earlier), so the fail-closed behavior is scoped to the surface
  that needs it.

* _ws_client_reason: return a 'missing_or_empty_peer bound=...'
  block reason instead of None, so the dispatcher's existing
  reason-based rejection path picks it up and the close gets logged
  with a machine-parseable token for diagnosability.

Behavior unchanged for:

* gated mode (auth_required=True) — early-returns True before the
  empty-peer check runs. The OAuth ticket is the auth at that point.
* explicit non-loopback bind (--host 0.0.0.0/::, or a specific LAN
  address, always with --insecure) — early-returns True before the
  empty-peer check runs. DNS-rebinding is still blocked by the
  Host/Origin guard in _ws_host_origin_is_allowed.
* legitimate loopback peers (client_host == '127.0.0.1' / '::1') —
  not affected by the empty-peer branch.

Regression tests added in tests/hermes_cli/test_dashboard_auth_ws_auth.py:

* test_empty_client_host_rejected_in_loopback_mode
* test_missing_client_object_rejected_in_loopback_mode
* test_empty_client_host_reason_is_block

Plus two regression guards to ensure the fix does not over-reach:

* test_empty_client_host_still_allowed_in_insecure_public_mode
* test_empty_client_host_still_allowed_in_gated_mode

All three new fail-closed tests fail without this patch (the helpers
return True / None for an empty peer) and pass with it. The 45
pre-existing tests in test_dashboard_auth_ws_auth.py continue to pass.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: salvage/15544-ws-empty-peer vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11337 on HEAD, 11337 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5952 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists labels Jun 21, 2026
@teknium1
teknium1 merged commit ed3d12a into main Jun 21, 2026
35 checks passed
@teknium1
teknium1 deleted the salvage/15544-ws-empty-peer branch June 21, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants