sec(ws): single-use ticket auth on /ws — drop JWT-in-URL leak (#550) - #580
Merged
Conversation
Closes the residual finding from the April 2026 UnderDefense
remediation pentest (3.2.1, residual CVSS ~2.1): JWT in the
WebSocket URL leaks into nginx access logs, browser history, and
upstream proxy logs, and the cookie-style same-origin handshake
opens a CSWSH surface.
New flow:
1. Browser POSTs /api/ws/ticket (JWT in Authorization header).
Backend mints a 32-byte urlsafe opaque ticket, stores it in
Redis with a 30s TTL, returns it.
2. Browser connects to /ws?ticket=<opaque>. Backend atomically
GETDELs the Redis key (single-use) and resolves to the
authenticated subject before websocket.accept().
3. Reconnects re-mint a fresh ticket; the JWT never enters the
WebSocket URL.
CSWSH is mitigated because POST /api/ws/ticket requires the JWT in
an Authorization header — a malicious page can't mint a ticket on
the victim's behalf without an explicit cross-origin request, which
CORS rejects.
Components:
- services/ws_ticket_service.py: mint + atomic-GETDEL consume.
Fails closed when Redis is down (mint raises 503; consume
returns None → /ws closes 4001).
- routers/ws_tickets.py: POST /api/ws/ticket (JWT-authed).
- main.py /ws: drop ?token= path entirely; require ?ticket=.
- frontend (utils/websocket.js + stores/network.js): fetch ticket
via existing axios bearer-auth defaults, then connect with
?ticket=. The two real /ws call sites are the only ones touched
— useProcessWebSocket.js is dead code (not imported anywhere)
and AgentTerminal/useVoiceSession use different endpoints.
Scope note: /ws/events keeps ?token=trinity_mcp_xxx for documented
external clients (websocat, wscat scripts). MCP keys are scoped,
named, and revocable so the leak surface is bounded relative to
a JWT.
Tests: 7 unit tests on ws_ticket_service covering mint+GETDEL
single-use, expired/missing/malformed handling, and Redis-down
fail-closed. Live smoke verified protocol end-to-end:
ticket → ws connect → ping/pong; same ticket reused → 403.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe
approved these changes
Apr 29, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
LGTM — clean security fix, 7 unit tests, browser-verified, architecture.md updated. Note: /ws/events still uses ?token= by design; recommend tracking a follow-up issue for that surface.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the residual finding from the April 2026 UnderDefense remediation pentest (3.2.1, residual CVSS ~2.1): JWT in the WebSocket URL leaks into nginx access logs, browser history, and upstream proxies, and same-origin auto-cookie behavior opens a CSWSH surface.
New flow
POST /api/ws/ticket(JWT inAuthorizationheader). Backend mints a 32-byte urlsafe opaque ticket, stores it in Redis with a 30s TTL, returns it./ws?ticket=<opaque>. Backend atomicallyGETDELs the Redis key (single-use, Redis 6.2+) and resolves to the authenticated subject beforewebsocket.accept().CSWSH is mitigated because
POST /api/ws/ticketrequires the JWT in anAuthorizationheader — a malicious page can't mint a ticket on the victim's behalf without an explicit cross-origin request, which CORS rejects.Components
services/ws_ticket_service.py— mint + atomic-GETDELconsume. Fails closed when Redis is down (mint raises 503; consume returnsNone→/wscloses 4001).routers/ws_tickets.py—POST /api/ws/ticket(JWT-authed).main.py/ws— drops?token=path entirely; requires?ticket=.utils/websocket.js+stores/network.js) — fetch ticket via existing axios bearer-auth defaults, then connect with?ticket=. The two real/wscall sites are the only ones touched (useProcessWebSocket.jsis dead code,AgentTerminal/useVoiceSessionuse different endpoints).Scope decision: /ws/events kept on ?token=
/ws/events(Trinity Connect for external listeners) keeps?token=trinity_mcp_xxxfor compatibility with documented external scripts (websocat,wscat). MCP keys are scoped, named, and revocable so the leak surface is bounded relative to a JWT. If we want to ticket-gate that surface too, it's a separate follow-up — adding tickets there would require updating the Trinity Connect docs and any deployed scripts.Closes #550.
Test plan
pytest unit/test_ws_ticket_service.py— 7/7 pass (mint+GETDEL single-use, expired/missing/malformed handling, Redis-down fail-closed).POST /api/ws/ticketunauthed → 401 ✅POST /api/ws/ticketwith JWT → 43-char ticket ✅/wsno params → 403 (rejected before accept) ✅/ws?ticket=<valid>→ connects, ping/pong works ✅/ws?ticket=<used>(replay) → 403 ✅ (single-use proven)websocket.js,network.js) URL-show?ticket=…with no JWT; status 101.vite build).Notes for reviewer
/ws?token=…and get rejected with 403 → frontend already handles a clean reconnect via the existingsetTimeout(connect, 5000)path. Worst case is one failed connect followed by hard refresh; no data loss.🤖 Generated with Claude Code