WebSocket Security · DoS Prevention · Protocol
MCP server WebSocket message framing security
WebSocket MCP servers expose attack surface that does not exist in HTTP-only servers: the RFC 6455 frame layer. Control frames (Ping, Pong, Close) have different rules than data frames, fragmented messages are assembled in memory before any handler code runs, and frame masking requirements protect against proxy cache poisoning. Three attack classes target this framing layer — ping flooding, fragmentation-based size bypasses, and control frame injection — and each requires a specific mitigation beyond standard HTTP security controls.
RFC 6455 frame basics
Every WebSocket message is carried in one or more frames. Each frame has: a FIN bit (1 = final fragment, 0 = more fragments follow), an opcode (0x0 = continuation, 0x1 = text, 0x2 = binary, 0x8 = close, 0x9 = ping, 0xA = pong), a masking bit (client frames must be masked; server frames must not), a payload length, a 4-byte masking key (if masked), and the payload. Control frames (close, ping, pong) have a maximum payload of 125 bytes and cannot be fragmented. Data frames (text, binary, continuation) can be arbitrarily large and can be fragmented across multiple frames.
Attack class 1: ping flood
A connected client can send Ping control frames (opcode 0x9) at any rate. RFC 6455 §5.5.3 requires the server to respond to each Ping with a Pong (opcode 0xA) "as soon as is practical." The Node.js ws library auto-responds to every Ping with a Pong synchronously — this auto-response cannot be disabled via the public API.
Sending 10,000 Ping frames per second from one connection saturates the event loop with ping processing, starving data frame handling and all other connections on the same server process. Unlike HTTP request floods, this targets the event loop directly through an obligation imposed by the protocol spec:
// The ws library emits a 'ping' event AND auto-sends a pong before the event fires
// You cannot prevent the auto-pong, but you can detect flooding and close the connection
const wss = new WebSocketServer({ noServer: true });
const PING_WINDOW_MS = 10_000;
const MAX_PINGS_PER_WINDOW = 20;
wss.on('connection', (ws) => {
let pings = 0;
let windowStart = Date.now();
ws.on('ping', () => {
const now = Date.now();
if (now - windowStart > PING_WINDOW_MS) {
pings = 0;
windowStart = now;
}
pings++;
if (pings > MAX_PINGS_PER_WINDOW) {
// auto-pong already sent — now close the abusive connection
ws.close(1008, 'Ping rate limit exceeded');
}
});
});
Per-connection limit: rate limit pings per connection, not globally. A legitimate agent SDK may send periodic pings to keep the connection alive — typically one every 30 seconds. Limit should be permissive for keep-alive (2/minute) but strict for flooding (20/10s).
Attack class 2: fragmented message size bypass
A WebSocket message split across N frames with FIN=0 on the first N-1 frames and FIN=1 on the last is reassembled by the ws library before the message event fires. The critical detail: reassembly happens in memory, with all fragment payloads concatenated into a single buffer. The maxPayload option controls the maximum reassembled size — if not set, the default is 104,857,600 bytes (100 MB).
An attacker can send a single message as 1,024 fragments of 100 KB each, assembling to 100 MB in memory before any tool handler code sees it. For a server running 20 concurrent connections, this is 2 GB of in-memory reassembly buffers per flood wave:
// UNSAFE: default maxPayload = 100 MB, fragmented messages assembly happens in ws internals
const wss = new WebSocketServer({ port: 8080 });
// SAFE: set maxPayload to 1 MB — ws closes connection with code 1009 if exceeded during reassembly
const wss = new WebSocketServer({
noServer: true,
maxPayload: 1 * 1024 * 1024, // 1 MB max reassembled message size
// ws will emit 'error' and close the connection automatically if exceeded
});
// Additional defence: reject individual frames over a threshold before they contribute to reassembly
// This requires a custom permessage-deflate extension handler or raw frame inspection
// For most servers, maxPayload is sufficient
Attack class 3: unmasked client frame injection (proxy desync)
RFC 6455 requires client-to-server frames to be masked and server-to-client frames to be unmasked. This asymmetry prevents "cache poisoning via WebSocket": a browser-initiated WebSocket could otherwise construct frames that look like HTTP responses to an intercepting proxy, poisoning the proxy's cache for other users.
The attack vector is a broken intermediary — a reverse proxy or load balancer that re-processes WebSocket frames without correct masking handling. If the proxy unmasks client frames but then forwards them as if they were server frames (unmasked), the ws library on the other end may accept them as server-originated data, bypassing client-origin validation in application code.
The ws library validates masking automatically and closes the connection with code 1002 on a masking violation. The risk is in proxy configurations that terminate and re-initiate WebSocket connections — ensure proxies are in passthrough mode, not re-proxy mode, for WebSocket traffic:
# Nginx — WebSocket passthrough (correct)
location /mcp {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# These headers tell nginx to pass the connection through, not buffer/re-proxy
}
# What to AVOID: setting proxy_buffering on for WebSocket endpoints
# proxy_buffering on; — causes nginx to buffer WebSocket frames, breaking masking assumptions
Control frame size enforcement
RFC 6455 §5.5 limits control frame payloads to 125 bytes. A client sending a Ping or Close frame with a longer payload is violating the protocol. The ws library rejects oversized control frames automatically and closes with code 1002. If you are implementing a raw WebSocket server without the ws library, explicitly check control frame payload length before any payload processing:
// Raw WebSocket frame validation (if not using ws library)
function parseFrame(buf) {
const opcode = buf[0] & 0x0f;
const isControl = (opcode >= 0x8);
const payloadLen = buf[1] & 0x7f;
if (isControl && payloadLen > 125) {
throw new ProtocolError('Control frame payload exceeds 125 bytes (RFC 6455 §5.5)');
}
if (isControl && !(buf[0] & 0x80)) {
throw new ProtocolError('Control frame has FIN=0 (control frames cannot be fragmented)');
}
// ... rest of frame parsing
}
SkillAudit findings for WebSocket framing
maxPayload option set on WebSocketServer. Fragmented messages are reassembled in-memory up to 100 MB per message per connection before any handler code runs. Grade impact: −10.
Related: WebSocket security in depth · Rate limiting deep dive · Payload size DoS security