Skip to content

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. Include company_id for 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"