{"repro_id":"REPRO-2026-00292","version":6,"title":"websocket-driver: message corruption via abuse of draft-WebSocket length headers","repro_type":"security","status":"published","severity":"critical","description":"CVE-2026-54466 / GHSA-xv26-6w52-cph6: websocket-driver (npm, faye/websocket-driver-node) versions < 0.7.5 mishandle an overlong variable-width length header in the legacy draft-75/draft-76 WebSocket parser. The base-128 length accumulator in _parseLeadingByte() case 1 grows without bound; since JS numbers are 64-bit floats, the declared length eventually loses integer precision, causing the subsequent payload to be misframed. Impact is protocol message corruption / message loss (Integrity High), not RCE or memory exhaustion. CVSS v4 9.2 Critical.","root_cause":"# RCA Report — CVE-2026-54466\n\n## Summary\n\n`websocket-driver` (npm package `websocket-driver`, repo `faye/websocket-driver-node`) versions before 0.7.5 mishandle an overlong variable-width length header in the legacy draft-75 / draft-76 (hixie-75 / hixie-76) WebSocket parser. The stage-1 base-128 length accumulator in `lib/websocket/driver/draft75.js` (`this._length = (octet & 0x7F) + 128 * this._length`) has no upper bound, so a small, attacker-controlled wire sequence makes the declared `_length` exceed `_maxLength` (`0x3ffffff` = 67108863). The driver stays open and enters stage-2 \"skip\" mode with a gigantic `_length`, which then consumes, loses, or misframes a subsequent valid text frame instead of delivering its intended message boundary. The 0.7.5 fix adds a per-octet guard `if (this._length > this._maxLength) return this.close();` immediately after accumulation.\n\n## Impact\n\n- **Package/component affected:** `websocket-driver`, the `Draft75`/`Draft76` parser path (`lib/websocket/driver/draft75.js`), reached via `Driver.server(...)` / `Server.parse(chunk)`.\n- **Affected versions:** `websocket-driver < 0.7.5` (representative vulnerable build `0.7.4`; fixed control `0.7.5`).\n- **Risk level and consequences:** Critical protocol-level message corruption / message loss on any server that accepts legacy hixie-75/76 framing. An attacker who can write raw bytes to an established connection can cause the parser to drop the framing boundary of the following legitimate frame and emit a stale/misframed application message, defeating message integrity. This is a denial-of-integrity / message-corruption issue, not RCE and not memory exhaustion; the wire allocation stays tiny.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Protocol message corruption / message loss (impact class `other`), via abuse of the draft-75/76 length header on a negotiated hixie-75/76 connection.\n- **Reproduced impact from this run:** On `0.7.4`, the driver remained open (`readyState=1`) after the oversized length header, did **not** deliver the uniquely-marked sentinel text frame, and instead re-emitted a **stale** copy of the previous (positive-control) message — i.e. the sentinel was consumed/misframed. On `0.7.5`, the identical stream closed (`readyState=3`, `close` event) while accumulating the over-limit length header and emitted no corrupted application message. Positive control delivered in both builds, proving the parser/listener path was operational.\n- **Parity:** `full` — the reproduced behavior matches the disclosed message-corruption/message-loss impact and the fixed-build negative control exactly.\n- **Not demonstrated:** No code execution, no memory exhaustion, no native crash — none claimed.\n\n## Root Cause\n\nIn `Draft75.prototype.parse` the per-octet state machine has three relevant stages:\n\n- **stage 0** (`_parseLeadingByte`): a leading byte with the high bit set (`(octet & 0x80) === 0x80`) initializes `_length = 0` and moves to **stage 1** (binary/control frame with a variable-width length header).\n- **stage 1** (length accumulator): for every octet, `_length = (octet & 0x7F) + 128 * this._length`. An octet whose high bit is clear is the terminator; if `_length !== 0` the parser transitions to **stage 2** with `_skipped = 0`.\n- **stage 2** (skip mode): while `_length` is truthy, each incoming octet increments `_skipped` until `_skipped === _length`; the only early exit is `octet === 0xFF`, which emits `Buffer.from(this._buffer)` as a message and resets to stage 0. Crucially, in the high-bit-set leading-byte branch `_parseLeadingByte` does **not** reset `this._buffer`, so the stale buffer from a prior text frame is still referenced.\n\nIn versions `< 0.7.5` there is **no bound check** on `_length` during stage 1. The minimal deterministic sequence\n\n```\n0x80  0xFF  0xFF  0xFF  0x7F\n```\n\ndrives the accumulator: `0 → 127 → 16383 → 2097151 → 268435455`. The terminating `0x7F` (high bit clear) produces `_length = 268435455`, which exceeds `_maxLength` (`0x3ffffff` = `67108863`), and the parser falls through to stage-2 skip mode. A following valid sentinel text frame (`0x00 <utf8 \"SENTINEL-VULN-XYZ-12345\"> 0xFF`) is then consumed byte-by-byte as \"skipped\" payload; when the sentinel's `0xFF` terminator is reached, stage 2 emits `Buffer.from(this._buffer)` — the **stale** previous message (`POSITIVE-CTRL-OK`) — and resets to stage 0. The sentinel text is never delivered; a corrupted/stale message is delivered in its place. The connection stays open, so the corruption is silent and ongoing.\n\nThe 0.7.5 patch (`lib/websocket/driver/draft75.js`, one added line in `case 1`) inserts the guard immediately after accumulation:\n\n```js\nthis._length = (octet & 0x7F) + 128 * this._length;\nif (this._length > this._maxLength) return this.close();   // <-- added in 0.7.5\n```\n\nso the over-limit header closes the driver (`readyState=3`, `close` event) before any sentinel is accepted and before any corrupted message can be emitted.\n\nFix reference: `websocket-driver@0.7.5` release — https://github.com/faye/websocket-driver-node/releases/tag/0.7.5 ; advisory GHSA-xv26-6w52-cph6.\n\n## Reproduction Steps\n\n1. Reference: `bundle/repro/reproduction_steps.sh` (driver script) and `bundle/repro/harness.js` (Node TCP peer harness). Both are self-contained; the script reuses the prepared project cache (`bundle/project_cache_context.json`, `prepared=true`) when available and otherwise falls back to `npm install websocket-driver@0.7.4` / `@0.7.5`.\n2. What the script does:\n   - Resolves the vulnerable (`0.7.4`) and fixed (`0.7.5`) published `websocket-driver` packages from the durable project cache (or installs them).\n   - Verifies the versions and the presence/absence of the `_maxLength` guard in `draft75.js` for each build.\n   - For each build, runs `harness.js`, which starts a real loopback TCP server using `net.createServer` + `Driver.server({requireMasking:false})`, pipes `connection ↔ driver.io`, and a raw TCP client performs a draft-75 handshake (no `sec-websocket-*` headers, so `Server.http` selects `Draft75`), waits for the `101` response, then sends (a) a positive-control text frame `0x00 \"POSITIVE-CTRL-OK\" 0xFF`, then (b) the malicious overlong length header `0x80 0xFF 0xFF 0xFF 0x7F` immediately followed by (c) a uniquely-marked sentinel text frame `0x00 \"SENTINEL-VULN-XYZ-12345\" 0xFF`. All emitted `open`/`message`/`close`/`error` events, the `readyState`, and the exact wire bytes are recorded to JSON.\n   - Evaluates the oracle: vulnerable build must deliver the positive control, stay open (no `close`), **not** deliver the sentinel, and emit a corrupted/stale message; fixed build must deliver the positive control, emit `close` on the malicious header, and emit no corrupted message.\n   - Writes `bundle/repro/runtime_manifest.json`, `bundle/repro/validation_verdict.json`, and best-effort copies proof artifacts into the project-cache proof-carry directory.\n3. Expected evidence of reproduction (see `bundle/logs/oracle_eval.txt`, `bundle/logs/vuln_result.json`, `bundle/logs/fixed_result.json`):\n   - **Vulnerable 0.7.4:** `positive_delivered=true`, `sentinel_delivered=false`, `close_after_malicious=false`, `corrupted_message_after_malicious=true`, `positive_count=2` (stale re-emit), `readyState_final=1`.\n   - **Fixed 0.7.5:** `positive_delivered=true`, `sentinel_delivered=false`, `close_after_malicious=true`, `corrupted_message_after_malicious=false`, `positive_count=1`, `readyState_final=3`.\n   - Oracle `CONFIRMED=true` only when both paths match.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full driver script transcript.\n- `bundle/logs/vuln_result.json` — events + wire bytes for `0.7.4`.\n- `bundle/logs/fixed_result.json` — events + wire bytes for `0.7.5`.\n- `bundle/logs/vuln_run.log`, `bundle/logs/fixed_run.log` — raw harness stdout per build.\n- `bundle/logs/oracle_eval.txt` — oracle evaluation summary.\n- `bundle/repro/runtime_manifest.json` — runtime manifest (`entrypoint_kind=tcp_peer`, `service_started=true`, `healthcheck_passed=true`, `target_path_reached=true`).\n- `bundle/repro/validation_verdict.json` — structured verdict (`claim_outcome=confirmed`).\n\nKey excerpt (vulnerable 0.7.4 events):\n\n```json\n[\n  {\"type\":\"open\"},\n  {\"type\":\"message\",\"data\":\"POSITIVE-CTRL-OK\"},\n  {\"type\":\"message\",\"data\":\"POSITIVE-CTRL-OK\"}   // stale re-emit; sentinel \"SENTINEL-VULN-XYZ-12345\" never delivered\n]\n```\n\nKey excerpt (fixed 0.7.5 events):\n\n```json\n[\n  {\"type\":\"open\"},\n  {\"type\":\"message\",\"data\":\"POSITIVE-CTRL-OK\"},\n  {\"type\":\"close\"}                                // guard closes on over-limit length header; no corrupted message\n]\n```\n\nWire bytes (hex): malicious header `80ffffff7f` → computed `_length = 268435455 > maxLength 67108863`; sentinel `0053454e54494e454c2d56554c4e2d58595a2d3132333435ff`.\n\nEnvironment: Node.js v24.x on Linux; `net` loopback TCP; `websocket-driver` 0.7.4 (vulnerable) and 0.7.5 (fixed) from the published npm package; no sanitizers (production path, `sanitizer_used=false`).\n\n## Recommendations / Next Steps\n\n- **Upgrade:** Pin `websocket-driver` to `>= 0.7.5` (the guard `if (this._length > this._maxLength) return this.close();` is the canonical fix).\n- **Defense in depth:** Where legacy hixie-75/76 framing is not required, disable draft-75/76 negotiation at the HTTP upgrade layer so `Server.http` never selects `Draft75`/`Draft76`.\n- **Testing:** Add a regression test that feeds `0x80 0xFF 0xFF 0xFF 0x7F` + a sentinel text frame through `Driver.server()` over a real socket and asserts (1) a `close` event fires and (2) no `message` event carries stale/sentinel content. Also assert the positive-control text frame still delivers.\n- **Variant exploration (next stage):** Confirm the same accumulator path is reachable through `Draft76` (hixie-76) once the 8-byte challenge body is satisfied, and check whether intermediate `_length` values just under `_maxLength` can still cause partial misframing without tripping the guard.\n\n## Additional Notes\n\n- **Idempotency:** The script was run twice consecutively with identical results (oracle `CONFIRMED=true` both times, identical event shapes and wire bytes); the only nondeterministic field is the event timestamp `t`.\n- **Minimal deterministic octet sequence derived from shipped source:** `0x80 0xFF 0xFF 0xFF 0x7F` (5 bytes) drives `_length` to `268435455`, the smallest terminator-using sequence that exceeds `_maxLength` (`67108863`) using all-high-bit continuation octets. The physical wire input (header + sentinel) is under 40 bytes — no resource exhaustion.\n- **Real package, real socket:** The proof exercises the **published** `websocket-driver` package through `Driver.server(...).parse(chunk)` over a real loopback TCP socket (not a copied parser or reimplementation), satisfying the `network_protocol` / `tcp_peer` entrypoint requirement.\n- **Limitations:** The oracle uses an in-process `net` loopback peer; no external network is involved. The driver's `close()` in the fixed build ends the connection before the sentinel, so `sentinel_delivered=false` in both builds — the discriminator is the `close` event plus the absence of a corrupted/stale message in the fixed build versus the stale re-emit + open state in the vulnerable build.\n","cve_id":"CVE-2026-54466","source_url":"https://github.com/faye/websocket-driver-node","package":{"name":"faye/websocket-driver-node","ecosystem":"npm","affected_versions":"< 0.7.5","fixed_version":"0.7.5"},"reproduced_at":"2026-07-16T11:51:35.032454+00:00","duration_secs":448.0,"tool_calls":127,"handoffs":2,"total_cost_usd":1.261662,"agent_costs":{"claim_matcher":0.016079,"judge":0.071862,"learning_policy":0.02093,"repro":0.472297,"support":0.052456,"vuln_variant":0.628038},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.016079},"judge":{"gpt-5.4-mini":0.039709,"gpt-5.4-mini-2026-03-17":0.032153},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.02093},"repro":{"accounts/fireworks/routers/glm-5p2-fast":0.472297},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.052456},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":0.628038}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":1,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-16T11:51:35.701852+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":11813,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":11301,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":7924,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":11911,"category":"analysis"},{"path":"bundle/repro/harness.js","filename":"harness.js","size":7021,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":926,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1578,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":1898,"category":"log"},{"path":"bundle/logs/vuln_result.json","filename":"vuln_result.json","size":765,"category":"other"},{"path":"bundle/logs/fixed_result.json","filename":"fixed_result.json","size":730,"category":"other"},{"path":"bundle/logs/vuln_run.log","filename":"vuln_run.log","size":766,"category":"log"},{"path":"bundle/logs/fixed_run.log","filename":"fixed_run.log","size":731,"category":"log"},{"path":"bundle/logs/oracle_eval.txt","filename":"oracle_eval.txt","size":1165,"category":"other"},{"path":"bundle/vuln_variant/variant_harness.js","filename":"variant_harness.js","size":7866,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6864,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3236,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1968,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":970,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1828,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1975,"category":"other"}]}