WebSocket Authentication¶
All WebSocket endpoints require JWT authentication via the token query parameter. Without this, an unauthenticated attacker on the same network could connect and receive real-time engagement data, runner output, and terminal streams.
Endpoints¶
The platform has four WebSocket endpoints. All now enforce JWT validation before accepting the connection.
| Endpoint | Purpose | Token Type | Required Role |
|---|---|---|---|
WS /ws/{engagement_name} |
Real-time engagement events | access |
admin, pentester, viewer |
WS /ws/client/{engagement_id} |
Client portal events | client |
client_admin, client_technical, client_viewer |
WS /api/v1/runner/sessions/{id}/stream |
Live runner output | access |
admin, pentester |
WS /api/v1/terminal/ws |
PTY terminal stream | access |
admin, pentester |
Authentication Flow¶
sequenceDiagram
participant Browser
participant Server
participant JWT as JWT Validator
Browser->>Server: WebSocket connect<br/>?token=eyJ...
Server->>JWT: decode_token(token)
alt Valid token
JWT-->>Server: {sub, type, exp}
Server->>Server: Verify token type matches endpoint
Server->>Browser: Connection accepted
Server->>Browser: Real-time events
else Missing token
Server->>Browser: Close 4001: Missing token
else Invalid token
Server->>Browser: Close 4003: Invalid token
else Wrong token type
Server->>Browser: Close 4003: Invalid token type
end
Token Types¶
access-- Dashboard admin/pentester/viewer tokens. Used on engagement WS, runner WS, and terminal WS.client-- Client portal tokens. Includecompany_idfor tenant isolation. Only accepted on the client WS endpoint.
The token type check prevents cross-portal access: a client token cannot connect to the internal engagement WebSocket, and an admin token cannot connect to the client portal WebSocket.
Frontend Integration¶
All frontend WebSocket connections pass the JWT token as a query parameter.
Engagement Events (useRealtime.ts)¶
const token = getAccessToken()
if (!token) return
const wsUrl = `${protocol}//${host}/ws/${engagementName}?token=${encodeURIComponent(token)}`
const ws = new WebSocket(wsUrl)
Runner Stream (useSessionStream.ts)¶
const token = getAccessToken()
if (!token) return
const wsUrl = `${protocol}//${host}/ws/runner/${sessionId}?token=${encodeURIComponent(token)}`
const ws = new WebSocket(wsUrl)
Terminal (TerminalEmulator.tsx)¶
// Token passed as prop from parent component
const wsUrl = `${protocol}//${host}/api/v1/terminal/ws?token=${encodeURIComponent(token)}`
const ws = new WebSocket(wsUrl)
Implementation Details¶
Server-Side Validation¶
All four endpoints follow the same pattern:
@app.websocket("/ws/{engagement_name}")
async def websocket_endpoint(
websocket: WebSocket,
engagement_name: str,
token: str = Query(""),
) -> None:
# Reject if no token provided
if not token:
await websocket.close(code=4001, reason="Missing token")
return
# Validate JWT
try:
payload = decode_token(token)
if payload.get("type") != "access":
await websocket.close(code=4003, reason="Invalid token type")
return
except Exception:
await websocket.close(code=4003, reason="Invalid token")
return
# Token valid — proceed with connection
await connection_manager.connect(engagement_name, websocket)
Close Codes¶
| Code | Reason | Meaning |
|---|---|---|
4001 |
Missing token | No token query parameter provided |
4003 |
Invalid token | JWT decode failed, expired, or wrong type |
4500 |
PTY unavailable | Terminal-specific: PTY library not installed |
Previous Vulnerability¶
Before this change, the engagement WebSocket (/ws/{engagement_name}) and client WebSocket (/ws/client/{engagement_id}) accepted connections without any authentication.
# BEFORE (vulnerable)
@app.websocket("/ws/{engagement_name}")
async def websocket_endpoint(websocket: WebSocket, engagement_name: str):
await connection_manager.connect(engagement_name, websocket)
# Anyone on the network could connect and receive:
# - context_updated events (tech stack, auth info)
# - finding_added events (vulnerability details)
# - phase_changed events (testing progress)
This meant anyone who could reach the server (same network, port scan, SSRF) could subscribe to engagement events and receive real-time vulnerability data without credentials.
Verification¶
# Should fail with code 4001 (no token)
wscat -c "ws://localhost:8880/ws/test-engagement"
# Should fail with code 4003 (invalid token)
wscat -c "ws://localhost:8880/ws/test-engagement?token=invalid"
# Should succeed (valid admin JWT)
TOKEN=$(curl -s -X POST http://localhost:8880/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r .access_token)
wscat -c "ws://localhost:8880/ws/test-engagement?token=$TOKEN"