Repository navigation
Conversation
ByteParser kept #info.compressed set after delivering a permessage-deflate message. The guard that rejects a continuation frame arriving with no message in progress is skipped when that flag is set, so a stray continuation frame following a compressed message was decompressed and delivered as an extra message instead of failing the connection with 1002. Clear the flag in consumeFragments(), where the message it describes is completed.
…pressed messages Adds four cases around the compressed-flag reset: a stray continuation separated from the completed message by a control frame, a stray continuation that carries a decompressible payload, two consecutive compressed messages, and a control frame interleaved into a fragmented compressed message.
VerificationIndependent adversarial verification by a fresh run, at head Result: the fix holds. Production source was not touched by this pass — One claim in the previous body was wrong, and it is now a testLedger row 9 — a control frame arriving between the completed compressed message and the stray continuation frame — had been dismissed as "not separately pinned — no diff behaviour varies on it". That is incorrect. Control frames never assign Row 5 ( Both arms at this head, verbatimAll 9 tests present in the file; only $ git checkout origin/main -- lib/web/websocket/receiver.js # base arm
$ node --test test/websocket/receiver-unit.js
✔ ByteParser rejects 64-bit payload lengths with a non-zero upper word (27.118782ms)
✖ ByteParser fails the connection on a continuation frame after a compressed message (119.98587ms)
✔ ByteParser fails the connection on a continuation frame after an uncompressed message (control) (105.086991ms)
✔ ByteParser still assembles a fragmented compressed message (control) (106.584768ms)
✖ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (157.444873ms)
✖ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.700874ms)
✖ ByteParser fails the connection on a non-empty continuation frame after a compressed message (105.319193ms)
✔ ByteParser delivers two consecutive compressed messages (control) (106.370579ms)
✔ ByteParser keeps the compressed flag across a control frame inside a fragmented message (control) (154.134819ms)
ℹ tests 9
ℹ pass 5
ℹ fail 4
✖ failing tests:
✖ ByteParser fails the connection on a continuation frame after a compressed message (119.98587ms)
actual: [ 'message:5', 'message:0' ],
expected: [ 'message:5', 'abort' ],
✖ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (157.444873ms)
actual: [ 'message:5', 'message:0' ],
expected: [ 'message:5', 'abort' ],
✖ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.700874ms)
actual: [ 'message:5', 'ping', 'message:0' ],
expected: [ 'message:5', 'ping', 'abort' ],
✖ ByteParser fails the connection on a non-empty continuation frame after a compressed message (105.319193ms)
actual: [ 'message:5', 'message:5' ],
expected: [ 'message:5', 'abort' ],
$ git checkout HEAD -- lib/web/websocket/receiver.js # fixed arm
$ node --test test/websocket/receiver-unit.js
✔ ByteParser rejects 64-bit payload lengths with a non-zero upper word (17.284235ms)
✔ ByteParser fails the connection on a continuation frame after a compressed message (113.10889ms)
✔ ByteParser fails the connection on a continuation frame after an uncompressed message (control) (102.501077ms)
✔ ByteParser still assembles a fragmented compressed message (control) (104.682335ms)
✔ ByteParser fails the connection on a continuation frame after a multi-fragment compressed message (156.259085ms)
✔ ByteParser fails the connection on a continuation frame separated from a compressed message by a control frame (155.52651ms)
✔ ByteParser fails the connection on a non-empty continuation frame after a compressed message (108.682308ms)
✔ ByteParser delivers two consecutive compressed messages (control) (107.327427ms)
✔ ByteParser keeps the compressed flag across a control frame inside a fragmented message (control) (154.650232ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0
ℹ duration_ms 1309.719693The 5 base-arm passers are exactly the 4 declared controls plus the pre-existing test. Every non-control test fails on base. Controls added by this passBoth were run against base before being committed, confirmed to pass on both arms, and named
Linter — the previous body's outstanding item, now closed
$ npx eslint --no-config-lookup --config <neostandard> test/websocket/receiver-unit.js lib/web/websocket/receiver.js
$ echo $?
0Zero errors, zero warnings on both touched files. The scratch symlink was removed; Behaviour outside the stated bugThe diff is a single unconditional assignment at the top of Not covered by this verification
|
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: APPROVE — ready for the operator to submit.
No blocking issues found in the live-head diff. I traced the changed reset at lib/web/websocket/receiver.js:372-376 through both completion paths: it clears stale per-message compression state before either the single-fragment early return or concatenating return, while preserving compression state for an incomplete fragmented message. The regression cases exercise both branches, a control-frame interleave, non-empty stray continuations, and the reverse consecutive-compressed-message case; the PR body supplies measured base/fixed evidence (4 discriminating failures on base, 9/9 passing at head).
What's good: the change is narrowly scoped, the tests validate the observable protocol outcome rather than internal state, and the facts sheet documents the unrun Autobahn/full-suite coverage candidly. Fork CI reports no checks for this branch (absence of a signal, not a failing check). I also checked the required facts sections, base implementation at 2a783e77, contribution policy, commit messages, and upstream prior-art searches; no duplicate open upstream PR was identified.
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).
Verdict: No blocking issues found; independently confirmed the bug and the fix at head 998d2a46.
What I checked
- Cloned the fork, checked out
998d2a4(HEAD), rannode --test test/websocket/receiver-unit.js: 9/9 pass, matching the PR body exactly. - Reverted only
lib/web/websocket/receiver.jstoorigin/main(base arm, tests kept from HEAD): 5 pass / 4 fail, and the four failing tests are exactly the four the PR body names, with identicalactual/expectedarrays. This independently reproduces the bug — I did not just trust the pasted console output. - Read
lib/web/websocket/receiver.js:171(the guard),:189-191(the only write site for#info.compressed, gated onisTextBinaryFrame(opcode)), and:369-397(consumeFragments(), where the fix adds the reset at:375). The trace holds: a continuation frame never reaches:191, so nothing else clears the flag between one message completing and the next frame's header being parsed.consumeFragments()is the only code path executed at message completion for both the uncompressed branch (:258) and the compressed branch (:290), so it's the correct single choke point — the twowebsocketMessageReceivedcall sites the PR body considered as alternatives would have needed the same fix duplicated twice for no benefit. - Grepped
nodejs/undiciopen/closed PRs and issues for "compressed flag", "continuation frame", "stale compressed" — no prior art on this exact defect. PR nodejs#5432 and nodejs#5435 touch the same file but aremaxPayloadSize-accounting fixes; confirmed via diff that neither reads/writes#info.compressed. - Checked
git logonlib/web/websocket/receiver.jsandtest/websocket/receiver-unit.js: both are actively maintained (12+ prior fragment/limit fixes on the source file, one prior fix on the unit-test file itself —84235c62, "Fix websocket 64-bit length overflow" — which is the same file, sametest/websocket/receiver-unit.jsunit-style, and touches the exact same guard region). This PR's shape (isolatedByteParserunit test, no server/client harness) matches that precedent well. gh pr checks 1reports "no checks reported" — Actions are not enabled on this fork, consistent with prior candidates from this operator.
Boundaries ledger (rebuilt from the diff)
| # | Predicate/state at receiver.js:171,191,375 | Input | Fixed behavior | Test that pins it |
|---|---|---|---|---|
| 1 | #info.compressed after a completed compressed message, next frame is continuation |
stray continuation, empty payload | abort (1002) |
"...after a compressed message" |
| 2 | same, but message was multi-fragment compressed (concat branch of consumeFragments, not the fragments.length===1 shortcut) |
stray continuation after 2-fragment compressed msg | abort |
"...after a multi-fragment compressed message" |
| 3 | same, but a control frame (ping) intervenes between message completion and the stray continuation | ping, then stray continuation | abort (ping does not launder the flag) |
"...separated...by a control frame" |
| 4 | same, but stray continuation carries a non-empty, decompressible payload | inline payload deflated | abort on opcode, not a zlib error |
"...non-empty continuation frame..." |
| 5 | #info.compressed after an uncompressed completed message (control, flag was never true) |
stray continuation | abort (unchanged behavior — this predicate was never affected) |
"...after an uncompressed message (control)" |
| 6 | continuation during an in-progress fragmented compressed message (#fragments.length > 0, so the 171 guard's second conjunct is false regardless of the flag) |
legal continuation | message delivered | "still assembles a fragmented compressed message (control)" |
| 7 | two consecutive compressed messages — flag must clear at #1's completion and be re-set correctly for nodejs#2 by :191 |
2nd compressed message | both delivered | "delivers two consecutive compressed messages (control)" |
| 8 | control frame inside (not after) a fragmented compressed message — flag must not be cleared by an interior control frame, only by completion | ping mid-fragment | fragmented message still assembles | "keeps the compressed flag across a control frame inside a fragmented message (control)" |
All 8 reachable rows I could construct from the diff are covered, and I confirmed row 1–4 are genuinely discriminating (fail on base, per the reproduction above) and rows 5–8 are true controls (pass on both arms for a reason connected to the diff, not vacuously — e.g. row 5 exercises the same guard with compressed structurally false throughout, row 7 is the reverse direction of the fix, row 8 pins that the reset point is completion and not "any control frame").
One row not covered by a unit test, but not a gap I'd block on: WebSocketStream (lib/web/websocket/stream/websocketstream.js:265) and WebSocket (lib/web/websocket/websocket.js:497) both construct their own ByteParser, but the fixed state (#info.compressed) is private to ByteParser itself — neither call site can observe or influence it. The PR body's row 14 argument is correct on inspection; I don't think this needs its own test, since it would just be receiver-unit.js's existing tests run through a second constructor call site with no different code in between. This is a judgement call the ticket asked about explicitly, and I don't have a specific execution path where the two front-ends diverge.
Idiom / maintainer's-eye notes
- Test style:
receiver-unit.jsis intentionally a bareByteParserunit test (stub handler, no real socket/server), which matches the file's one pre-existing test and its only prior fix (84235c62). Good choice over adding this totest/websocket/fragments.jsorcontinuation-frames.js, which use a realwsserver and would make the discriminating assertions much slower and noisier. - The
(control)suffix naming convention for both-arms-green tests is not an existing upstream idiom (I found no prior instance of it anywhere else intest/) — it's fork-local practice. Harmless and self-documenting, but a maintainer seeing it for the first time may ask about it or just accept it silently; not a blocking concern either way. - PR title
fix(websocket): reset the compressed flag when a message completesmatches the conventional-commit style used by the other recentwebsocket:fixes in this file's history (e.g.fix(websocket): enforce max payload size across fragments,fix(websocket): add maxDecompressedMessageSize limit...). - No CHANGELOG.md exists in this repo to update, and the PR body follows the repo's own
PULL_REQUEST_TEMPLATE.mdshape (Rationale / Changes / Bug Fixes) closely, matching nodejs#5432/nodejs#5435, two other currently-open PRs against this same file. - Autobahn (the authoritative conformance suite for a protocol-correctness change like this) has not run — the fork has GitHub Actions disabled, so only the added unit tests provide coverage. The PR body already discloses this; I'd flag it as the one thing I'd want resolved before treating this as fully validated, but it's an infrastructure gap, not a defect in the diff.
What's good
- Single, minimal production line (
receiver.js:375), correctly placed at the one code path both message-completion routes funnel through. - Every added test is deliberately labeled control vs. discriminating, and I verified both arms actually behave that way — no vacuous "both green" tests slipped through here.
- The non-empty-payload continuation test (row 4) is a nice touch that most contributors wouldn't have bothered with; it rules out the alternative explanation ("fails because zlib chokes on garbage input" vs. "fails because the opcode is rejected outright").
SECOND READ: READY
|
Upstream submission complete. |
Summary
ByteParsernever cleared#info.compressedafter delivering a permessage-deflate message, so the flag described the previous message while the next frame header was parsed.lib/web/websocket/receiver.js:171that rejects a continuation frame with no message in progress is conditioned on!this.#info.compressed, so it was skipped after any compressed message.consumeFragments(), the one place where the message the flag describes is completed (bothwebsocketMessageReceivedcall sites go through it).test/websocket/receiver-unit.js(plus 1 pre-existing test in the file, 9 total): 4 discriminate on this bug and 4 are declared controls.Both arms below were measured at head
998d2a463b8c348f1c3029ba728a818e3bb423c7, with all 9 tests present in the file and onlylib/web/websocket/receiver.jsswapped between arms.Upstream
nodejs/undicimain2a783e77dee2212366dc000e1fe0bd9fb6c919d4lib/web/websocket/receiver.js(ByteParser.consumeFragments, +5 lines),test/websocket/receiver-unit.js(+227 lines, 8 new tests)998d2a46: 2 files changed, 232 insertions(+), 0 deletions(-)Bug
When a permessage-deflate message is received,
run()setsthis.#info.compressed = rsv1 !== 0atreceiver.js:191while parsing the frame header, andconsumeFragments()hands the assembled message to the application. The flag is never reset, so it staystrueuntil the next text or binary frame header overwrites it at line 191. A continuation frame (opcode0x0) carries no such assignment, so between a completed compressed message and the next data frame,#info.compressedis stale.The guard at
receiver.js:171readsisContinuationFrame(opcode) && this.#fragments.length === 0 && !this.#info.compressed. The!this.#info.compressedterm exists because a compressed fragmented message legitimately drains#fragmentson each decompressed chunk while remaining in progress. But with the flag stale, that same term suppresses the guard for a continuation frame that follows a completed message. The parser then treats the stray frame as message data, feeds it to the inflater, and emits a secondmessageevent. Trigger: any peer (or on-path attacker able to inject a frame) that sends a continuation frame after a compressed message on a connection where permessage-deflate was negotiated. Blast radius: everyundiciWebSocket client that negotiates permessage-deflate — the default when the server offers it — receives spurious application messages on a connection the spec requires to be failed. It is a protocol-conformance hole, and the extra message is attacker-chosen data delivered as though it were a real message.Repro
repro.jsat the repository root, drivingByteParserdirectly (nonode_modulesneeded). Historical transcript, measured at the fix commita3cf4645against base2a783e77— the script is deliberately not part of the diff, and the identical scenario is pinned at the current head by the first regression test below, whose verbatim both-arm output is in## Summary:The repro writes one complete compressed text frame (
FIN|RSV1, opcode0x1, deflate-raw payload with the trailing00 00 ff ffstripped) followed by a bare80 00continuation frame.Fix
consumeFragments () { const fragments = this.#fragments + // The compressed flag describes the message that is being completed here. + // It must not survive into the next message, otherwise a stray + // continuation frame is accepted instead of failing the connection. + this.#info.compressed = false + if (fragments.length === 1) {consumeFragments()is the correct place: it is called from exactly the twowebsocketMessageReceivedsites (receiver.js:258uncompressed,receiver.js:290compressed), i.e. every point where a message completes, and it already owns the rest of the per-message state reset (#fragments,#fragmentsBytes). Clearing the flag there keeps the reset with the state it belongs to and costs one assignment per message.Alternatives rejected:
websocketMessageReceivedcall sites. Same effect, duplicated in two places, and a future third completion path would miss it.!this.#info.compressedfrom the guard at line 171. Reverts the fix from permessage-deflate decompression support in websocket nodejs/undici#3263 and breaks compressed fragmented messages, which drain#fragmentswhile still in progress — the...still assembles a fragmented compressed message (control)test is the control that would catch that.Test evidence
9 tests now live in
test/websocket/receiver-unit.js: 1 pre-existing, 4 added with the fix (3 ina3cf4645, 1 inffb1247b), and 4 added by the independent verification pass (998d2a46). All follow the file's existing unit-level style: constructByteParserwith a stub handler, no server. The sharedcreateRecordingHandler()records everyonMessage/onPing/abort/closethe parser produces, so the assertions compare full event sequences rather than a single flag.ByteParser rejects 64-bit payload lengths with a non-zero upper word...continuation frame after a compressed message['message:5','message:0']vs expected['message:5','abort']...continuation frame after an uncompressed message (control)...still assembles a fragmented compressed message (control)...continuation frame after a multi-fragment compressed messageffb1247b['message:5','message:0']vs expected['message:5','abort']...continuation frame separated from a compressed message by a control frame998d2a46['message:5','ping','message:0']vs expected['message:5','ping','abort']...non-empty continuation frame after a compressed message998d2a46['message:5','message:5']vs expected['message:5','abort']ByteParser delivers two consecutive compressed messages (control)998d2a46ByteParser keeps the compressed flag across a control frame inside a fragmented message (control)998d2a46Verbatim base/fixed output for all 9 is in the
## Summaryconsole block above.What each control controls for, one thing each:
fragments.length === 0term still fires when the flag was never set, so a future change cannot make the guard vacuous and still pass.!compressedterm exists at all, is not broken by the reset.The four discriminating tests differ in the code path that reaches the guard: T2 the single-fragment
consumeFragments()shortcut, T5 the concatenating branch, T6 a control frame between the message and the stray continuation, T7 a stray continuation carrying a decompressible payload (proving the failure is on the opcode, not incidentally on a zlib error).Linter: run and clean.
eslint@9+neostandard@0.13(the lintereslint.config.jsconfigures, matching the repo'snpm run lint) were installed into a scratch directory and linked in, then:Zero errors, zero warnings on both touched files. The scratch
node_modulessymlink was removed afterwards;git status --porcelainis empty at head.Verification method
executed— Node.js v24.19.0 on Linux, vianode --test(the repo'stest:websocketscript usesborp, which needsnode_modules;node --testruns the same file unmodified). Both arms were run at the final head by reverting onlylib/web/websocket/receiver.jstoorigin/mainwith all 9 tests in place, then restoring withgit checkout HEAD -- <file>(not acpbackup, which would leave the index dirty). Each arm was guarded bygrep -c 'compressed = false' lib/web/websocket/receiver.js(0 = base, 1 = head) in the same shell call as the test run, so no arm could silently run the wrong source.Two passes at two heads:
ffb1247b998d2a46In both passes the base-arm passers are exactly the declared controls, and every non-control test fails on base.
Production source is byte-identical across both heads:
git diff ffb1247b 998d2a46 -- lib/is empty. The verification pass added tests only.Head integrity measured, not inferred:
git status --porcelainempty andgit diff HEAD --statempty after the final commit;git diff origin/main..HEAD --stat= 2 files, +232/-0.Fork CI:
gh pr checks 1 --repo askalf/undici→no checks reported on the 'fix/websocket-stale-compressed-flag' branch. GitHub Actions has never been enabled onaskalf/undici, so this is an absence of a signal, not a failure — there are no non-green jobs to explain. Upstream CI, including the Autobahn conformance suite, which is the authoritative check for a protocol-conformance change like this one, has not run against this branch and remains outstanding.Prior art
gh search prs --repo nodejs/undici "continuation frame" --limit 15→ fix websocket empty fragments parsing nodejs/undici#5424 "fix websocket empty fragments parsing" (CLOSED, empty-fragment parsing, superseded by merged commitc5ed7875), feat: implement websockets nodejs/undici#1795 "feat: implement websockets" (merged, the original implementation). Neither touches the compressed-flag lifecycle.gh search prs --repo nodejs/undici "permessage-deflate" --limit 15→ open fix(websocket): validate inline payload with current opcode nodejs/undici#5432 "validate inline payload with current opcode" and open fix(websocket): enforce deflate remaining payload budget nodejs/undici#5435 "enforce deflate remaining payload budget". Both aremaxPayloadSizeaccounting in the same file; neither resets or reads#info.compressedoutside the existing sites. No conflict, no overlap.gh search prs --repo nodejs/undici "compressed flag reset" --limit 10→[].gh search issues --repo nodejs/undici "continuation frame" --limit 15→[];gh search issues --repo nodejs/undici "unexpected continuation frame"→[].git log -S'!this.#info.compressed' -- lib/web/websocket/receiver.js→5a564bd9"permessage-deflate decompression support in websocket (permessage-deflate decompression support in websocket nodejs/undici#3263)", which introduced the guard term. The stale-flag hole has been present since that commit.git log --oneline -12 -- lib/web/websocket/receiver.js→ the last twelve changes are fragment/limit/overflow fixes (c5ed7875,32dbf0b3,b4c287b3,84235c62, …), i.e. exactly this bug class, and several are by the maintainer who owns this file.Policy
CONTRIBUTING.md,CODE_OF_CONDUCT.mdand.github/PULL_REQUEST_TEMPLATE.mdcontain no mention of AI, LLM, generated or agent contributions — grep forai|llm|generated|agent|copilot|chatgpt|claudereturns only unrelated hits (deps/llhttp/...). Policy is silent, not a ban. There is noAGENTS.mdand no.github/CONTRIBUTING*..github/PULL_REQUEST_TEMPLATE.md: "You will also need to ensure that your contribution complies with the Developer's Certificate of Origin, outlined in CONTRIBUTING.md", with the checkbox "- [ ] I have read and agreed to the [Developer's Certificate of Origin][cert]".CONTRIBUTING.md:354: "Developer's Certificate of Origin 1.1 — By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file...". 13 of the last 30 upstream commits carry aSigned-off-bytrailer, so it is not mechanically enforced — the operator decides whether to add-swhen submitting; that certification is theirs to make, not ours.## Test evidence.mainperCONTRIBUTING.md:170).Disclosure facts for the operator
Plain facts, for you to word your own disclosure:
lib/web/websocket/receiver.jsand tracing the lifetime of#info.compressedagainst the guard at line 171. It was not reported by a user or taken from an issue.eslint+neostandard) on both touched files: clean.borp-basedtest:websocketscript (the full websocket suite), and did not run the Autobahn conformance suite.nodejs/undiciby the AI: no issues, comments, reviews or PRs. All work happened on theaskalf/undicifork.Signed-off-bytrailer.Boundaries
One row per predicate, comparison or truthiness check the diff adds or changes. The diff adds a single unconditional assignment, so the rows below enumerate the state that assignment feeds — the guard at
receiver.js:171, the branch atreceiver.js:248, the branch insideconsumeFragments(), and the twoByteParserconstruction sites. Ledger rebuilt from the diff by the verification pass; rows 5, 8, 9 and 12–14 were re-derived and their previous dispositions re-tested rather than inherited.#info.compressedafter an uncompressed message completesfalse(was alreadyfalse)#info.compressedafter a compressed message completesfalse(wastrue)consumeFragments()single-fragment branch (fragments.length === 1, earlyreturn)return, so it runsconsumeFragments()concat branch (fragments.length > 1)consumeFragments()withfragments.length === 0consumeFragments()on a fresh parser returns an emptyBuffer(fragments.length === 1is false, so it takes the concat branch with#fragmentsBytes === 0) — no throw, no crash. Both internal call sites (:258, :290) are immediately preceded by awriteFragments()that pushes, so the count is ≥1 at every real call!fin), guard at :171consumeFragments()has not run, flag staystrue!compressedterm exists for, preserved:248(!this.#info.compressed) for the frame after a compressed messagefalse, but line 191 overwrites it fromrsv1before this branch is reached for any text/binary frame:248else-branchpayloadLengthis consumedparseControlFrameat:244and never assign#info.compressed(only theisTextBinaryFramebranch at :189–192 does)false; the stray continuation frame is still rejected after the ping is answered['message:5','ping','message:0'])rsv1set:135("Expected RSV1 to be clear"), never reaches the diff:135, unreachable from the diff#infobefore any message ({},compressed === undefined)consumeFragments()cannot run before a message completesconsumeFragments()is only called from the two completion sitesrsv1at :191 for message 2consumeFragments()has not run, so the flag must survive the control frametrue; the message completes normally after the pingByteParserconstruction site —WebSocketStream(lib/web/websocket/stream/websocketstream.js:265) vsWebSocket(lib/web/websocket/websocket.js:497)ByteParserclass with the sameparsedExtensions; the flag andconsumeFragments()live entirely insideByteParserByteParserdirectly, i.e. below both call sites, so they pin the behaviour both consumers getSuggested upstream PR title
fix(websocket): reset the compressed flag when a message completes