js-websocket-frame-masking-bug
The buggy parser had three defects, all fixed in ~/websocket-frame-parser.js (parser + RFC 6455 encoder, with tests in ~/test-websocket-frame-parser.js):
Fix 1 — Masking is driven by the MASK bit, never by connection direction. RFC 6455 §5.1 says all client→server frames MUST be masked and server→client frames MUST NOT — but a wire frame is self-describing. The parser must unmask iff (b1 & 0x80) !== 0, reading the 4-byte key that follows the (possibly extended) length field:
const masked = (b1 & 0x80) !== 0; // bit is the only source of truth
...
let maskKey = null;
if (masked) {
if (this.buffer.length < offset + 4) break; // partial header: wait
maskKey = this.buffer.subarray(offset, offset + 4);
offset += 4;
}
if (this.buffer.length < offset + payloadLen) break; // partial payload
let payload = this.buffer.subarray(offset, offset + payloadLen);
if (masked && payloadLen > 0) {
payload = Buffer.from(payload); // copy before mutating shared buffer
for (let i = 0; i < payloadLen; i++) {
payload[i] ^= maskKey[i & 3]; // XOR, key index cycles (i mod 4)
}
}
Fix 2 — Extended lengths decode big-endian (network byte order). The buggy code read the 126/127 extended fields little-endian, corrupting every payload ≥ 126 bytes. RFC 6455 §5.2 specifies the 16-bit and 64-bit lengths be transmitted most-significant-byte first:
if (payloadLen === 126) {
payloadLen = this.buffer.readUInt16BE(2); // BIG-endian, was readUInt16LE
offset = 4;
} else if (payloadLen === 127) {
const hi = this.buffer.readUInt32BE(2); // BIG-endian
const lo = this.buffer.readUInt32BE(6);
payloadLen = hi * 0x100000000 + lo;
if (payloadLen > Number.MAX_SAFE_INTEGER) throw new Error('... exceeds safe range');
offset = 10;
}
Fix 3 — Fragmented messages reassemble in order; interleaving is rejected. Fragments (FIN=0 + CONTINUATION frames) accumulate into fragments[] and are Buffer.concated only when FIN=1 arrives, preserving order. Per RFC 6455 §5.4, control frames (PING/PONG/CLOSE) may be interleaved and are emitted immediately, but a second data frame mid-fragmentation is an error, not an interleave:
if (opcode === 0x0) { // CONTINUATION
if (this.fragmentOpcode === null) throw new Error('Unexpected continuation frame');
this.fragments.push(payload);
if (fin) { /* Buffer.concat(this.fragments) -> message; reset state */ }
} else if (opcode === 0x1 || opcode === 0x2) { // new TEXT/BINARY
if (this.fragmentOpcode !== null) throw new Error('Interleaved data frame during fragmented message');
fin ? emit single-frame message : { this.fragmentOpcode = opcode; this.fragments = [payload]; }
}
The parser is streaming-safe: push() concatenates into a tail buffer and loops until a frame's header/payload is incomplete (break), so frames split at arbitrary byte boundaries (TCP segmentation) reassemble correctly. A matching encodeFrame() builds frames with the correct 16/64-bit big-endian lengths and optional random 4-byte masking key.
Verified with Node's built-in test runner — **23/23 tests pass** (`node --test test-websocket-frame-parser.js`). Hand-rolled, independent RFC 6455 encoders cross-check the parser in both directions. Edge cases covered: | Category | Edge cases tested | |---|---| | Masking | MASK=1 unmasked with key; MASK=0 passes bytes through byte-identical; both directions in one stream (bit-driven, not direction-driven); key rotation over payloads > 4 bytes (`i & 3`); zero-length masked payload | | Lengths | 16-bit path at 126, 127, 128, 1000, 32768, 65535 (exact byte-identical payloads); 16-bit + masking; 64-bit path at 70000 with first/last 8 bytes spot-checked; 64-bit length exceeding `MAX_SAFE_INTEGER` rejected; encoder/parser round-trips at 126 / 65535 / 70000 | | Fragmentation | 2-frame and 3-frame messages reassemble in order; 3 sequential messages (alpha/beta/gamma) stay ordered and unmerged; fragmented text retains TEXT type; interleaved PING between fragments surfaces immediately while message reassembles; interleaved *data* frame rejected; orphan CONTINUATION rejected; single-frame message not delayed | | Streaming | 1-byte drip feed across a 4-frame stream reassembles all messages; multiple frames in one chunk parse in order | | Control/validation | CLOSE status code (1000) parsed big-endian with reason; fragmented control frame rejected; control payload > 125 rejected; RSV bits and invalid opcodes rejected | Runtime demo confirmed a 200-byte masked client frame decodes to 200×`0x41` (the old little-endian 126-path would truncate/corrupt it), and a fragmented `Hel`+`lo` message with an interleaved ping yields `ping:?` then `text:Hello`. All APIs used (`node:test`, `Buffer` BE readers, `BigInt`) are stable in Node 20.
{"model": "deepseek-v4-flash", "problem_class": "js-websocket-frame-masking-bug", "result": "passed", "tests": 23}The buggy parser had three defects, all fixed in ~/websocket-frame-parser.js (parser + RFC 6455 encoder, with tests in ~/test-websocket-frame-parser.js):
Fix 1 — Masking is driven by the MASK bit, never by connection direction. RFC 6455 §5.1 says all client→server frames MUST be masked and server→client frames MUST NOT — but a wire frame is self-describing. The parser must unmask iff (b1 & 0x80) !== 0, reading the 4-byte key that follows the (possibly extended) length field:
const masked = (b1 & 0x80) !== 0; // bit is the only source of truth
...
let maskKey = null;
if (masked) {
if (this.buffer.length < offset + 4) break; // partial header: wait
maskKey = this.buffer.subarray(offset, offset + 4);
offset += 4;
}
if (this.buffer.length < offset + payloadLen) break; // partial payload
let payload = this.buffer.subarray(offset, offset + payloadLen);
if (masked && payloadLen > 0) {
payload = Buffer.from(payload); // copy before mutating shared buffer
for (let i = 0; i < payloadLen; i++) {
payload[i] ^= maskKey[i & 3]; // XOR, key index cycles (i mod 4)
}
}
Fix 2 — Extended lengths decode big-endian (network byte order). The buggy code read the 126/127 extended fields little-endian, corrupting every payload ≥ 126 bytes. RFC 6455 §5.2 specifies the 16-bit and 64-bit lengths be transmitted most-significant-byte first:
if (payloadLen === 126) {
payloadLen = this.buffer.readUInt16BE(2); // BIG-endian, was readUInt16LE
offset = 4;
} else if (payloadLen === 127) {
const hi = this.buffer.readUInt32BE(2); // BIG-endian
const lo = this.buffer.readUInt32BE(6);
payloadLen = hi * 0x100000000 + lo;
if (payloadLen > Number.MAX_SAFE_INTEGER) throw new Error('... exceeds safe range');
offset = 10;
}
Fix 3 — Fragmented messages reassemble in order; interleaving is rejected. Fragments (FIN=0 + CONTINUATION frames) accumulate into fragments[] and are Buffer.concated only when FIN=1 arrives, preserving order. Per RFC 6455 §5.4, control frames (PING/PONG/CLOSE) may be interleaved and are emitted immediately, but a second data frame mid-fragmentation is an error, not an interleave:
if (opcode === 0x0) { // CONTINUATION
if (this.fragmentOpcode === null) throw new Error('Unexpected continuation frame');
this.fragments.push(payload);
if (fin) { /* Buffer.concat(this.fragments) -> message; reset state */ }
} else if (opcode === 0x1 || opcode === 0x2) { // new TEXT/BINARY
if (this.fragmentOpcode !== null) throw new Error('Interleaved data frame during fragmented message');
fin ? emit single-frame message : { this.fragmentOpcode = opcode; this.fragments = [payload]; }
}
The parser is streaming-safe: push() concatenates into a tail buffer and loops until a frame's header/payload is incomplete (break), so frames split at arbitrary byte boundaries (TCP segmentation) reassemble correctly. A matching encodeFrame() builds frames with the correct 16/64-bit big-endian lengths and optional random 4-byte masking key.
Verified with Node's built-in test runner — **23/23 tests pass** (`node --test test-websocket-frame-parser.js`). Hand-rolled, independent RFC 6455 encoders cross-check the parser in both directions. Edge cases covered: | Category | Edge cases tested | |---|---| | Masking | MASK=1 unmasked with key; MASK=0 passes bytes through byte-identical; both directions in one stream (bit-driven, not direction-driven); key rotation over payloads > 4 bytes (`i & 3`); zero-length masked payload | | Lengths | 16-bit path at 126, 127, 128, 1000, 32768, 65535 (exact byte-identical payloads); 16-bit + masking; 64-bit path at 70000 with first/last 8 bytes spot-checked; 64-bit length exceeding `MAX_SAFE_INTEGER` rejected; encoder/parser round-trips at 126 / 65535 / 70000 | | Fragmentation | 2-frame and 3-frame messages reassemble in order; 3 sequential messages (alpha/beta/gamma) stay ordered and unmerged; fragmented text retains TEXT type; interleaved PING between fragments surfaces immediately while message reassembles; interleaved *data* frame rejected; orphan CONTINUATION rejected; single-frame message not delayed | | Streaming | 1-byte drip feed across a 4-frame stream reassembles all messages; multiple frames in one chunk parse in order | | Control/validation | CLOSE status code (1000) parsed big-endian with reason; fragmented control frame rejected; control payload > 125 rejected; RSV bits and invalid opcodes rejected | Runtime demo confirmed a 200-byte masked client frame decodes to 200×`0x41` (the old little-endian 126-path would truncate/corrupt it), and a fragmented `Hel`+`lo` message with an interleaved ping yields `ping:?` then `text:Hello`. All APIs used (`node:test`, `Buffer` BE readers, `BigInt`) are stable in Node 20.
{"model": "deepseek-v4-flash", "problem_class": "js-websocket-frame-masking-bug", "result": "passed", "tests": 23}