fix: bound the inbound QoS 2 receive window (GHSA-5jvp-3p2v-5qgf) - #1154
Merged
Merged
Conversation
A client that can connect and publish QoS 2 could keep sending fresh message identifiers while withholding PUBREL, and the broker retained every packet body in the per-client incoming persistence map. queueLimit only guards the pre-CONNACK parser queue, so nothing bounded this. Reported by Team Atlanta. maxInflightInbound (default 1000, 0 = unlimited) caps how many inbound QoS 2 PUBLISH one client may hold awaiting PUBREL. The slot is reserved synchronously, before the store, and keyed by messageId. Both matter, and an earlier revision of this fix got both wrong: - enqueue in lib/client.js dispatches every packet of a socket read chunk concurrently. Checking a counter before `await incomingStorePacket` and incrementing after it let the entire chunk past the guard: with a cap of 3, a 40-packet chunk stored all 40 and never fired clientError. Reserving at the check closes that. - Keying by messageId rather than counting means a DUP retransmission of a packet already in flight re-reserves its own slot instead of consuming a second one. A counter drifted upward on every legitimate retransmission until a well-behaved client sat at the cap and got dropped — an availability regression caused by the fix. pubrel releases the slot only when incomingDelPacket actually resolves; freeing it after a failed delete would let the store grow past the cap. The rejection is now handled rather than riding out of .finally() uncaught, where a duplicate PUBREL losing the race killed the process. The store and the reservation now have the same lifetime: the preceding commit discards a clean session's incoming store when the connection ends, so a client can no longer reconnect into a fresh budget while its previous packets are still retained. Without that the cap bounded a connection, not a session. maxInflightInbound is clamped: a negative, fractional or non-numeric value falls back to the default instead of silently taking the `limit > 0` false branch and disabling the control. Only an explicit 0 opts out. Default is 1000, not 100. MQTT 3.1.1 has no Receive Maximum to advertise the window, so a pipelining publisher cannot know to throttle; 100 is inside the range mqtt.js reaches on a burst and would have dropped legitimate clients on a patch release. 1000 still bounds the map far below the 65535 messageId ceiling. Known limitation, documented: this caps packets, not bytes. aedes sets no maximum packet size, so the byte bound is maxInflightInbound x max frame size.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1154 +/- ##
=======================================
Coverage 99.90% 99.90%
=======================================
Files 15 15
Lines 2077 2127 +50
=======================================
+ Hits 2075 2125 +50
Misses 2 2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The duplicate-PUBREL test sends both sequentially, so the second returns early on foundInStore and never reaches the rejection handler. Forcing incomingDelPacket to reject exercises it: PUBCOMP is still owed, and the slot must stay while the packet is still stored.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes GHSA-5jvp-3p2v-5qgf. Reported by Team Atlanta.
Builds on the GHSA-p8r9 fix, already on
mainas c7acaa1 — the dependency is real, not packaging: see"Why it needs p8r9". Also needs
aedes-persistence11.0.0.The bug
Inbound QoS 2 packet bodies were retained in the per-client incoming persistence map with no bound on how many
a single connection could accumulate —
queueLimitonly guards the pre-CONNACK parser queue. Unboundedattacker-controlled retention, so memory growth until the broker degrades.
Trigger and full analysis are in the advisory; they stay there until it publishes.
Two things the first attempt got wrong
The original fix used a per-client integer counter, checked before
await incomingStorePacketand incrementedafter. Deep review found it did not work, and I reproduced both failures:
1. The cap was bypassable by the exact flood it targets.
enqueueinlib/client.jsdispatches every packetof a socket read chunk concurrently, so a whole chunk passed the check before any increment landed — the cap was
bypassable by the exact flood it targets, with no
clientErrorraised.2. It broke legitimate clients. Two concurrent PUBLISHes with the same messageId — an ordinary QoS 2 DUP
retransmission — both missed the store, both stored, both incremented; the single PUBREL decremented once. The
counter drifted upward until a well-behaved retransmitting client sat at the cap and got dropped. An availability
regression caused by the fix.
The fix
The slot is reserved synchronously, before the store, and keyed by messageId. One change closes both:
reserving at the check beats the concurrent chunk, and keying by messageId means a DUP re-reserves its own slot
instead of consuming a second one.
On this branch the same regression test reports
maxInflightInbound exceededand the cap holds.Also fixed:
pubrelreleases the slot only whenincomingDelPacketactually resolves. Freeing it after a failed deletewould let the store grow past the cap. The rejection is now handled rather than riding out of
.finally()uncaught, where a duplicate PUBREL losing the race killed the process.
maxInflightInboundis clamped.-1,NaN,1.5or a non-numeric config previously took thelimit > 0false branch and silently disabled the security control. They now fall back to the default; only an explicit
0opts out.
queueLimit'sClient queue limit reachedconvention,instead of borrowing MQTT 5 "Receive Maximum" vocabulary for a v3-only feature.
Why it needs p8r9
The counter is per-connection; the store it bounds is per-clientId. Without p8r9 nothing ever cleared the incoming
store, so a client could reconnect into a fresh budget while its previous packets were still retained — 655
reconnects still reaches the 65535 ceiling. p8r9 discards a clean session's store when the connection ends, which
gives the reservation and the data it represents the same lifetime. On its own this PR bounds a connection, not a
session.
Default is 1000, not 100
MQTT 3.1.1 has no Receive Maximum to advertise the window, so a pipelining publisher cannot know to throttle.
Benchmarked: 100 is inside the range mqtt.js reaches on a burst (60 publishes/chunk × 3 chunks was dropped), which
would have disconnected legitimate publishers on a patch release. 1000 still bounds the map far below the 16-bit
messageId ceiling.
0restores pre-fix behaviour and is documented as doing exactly that.Known limitation, documented
This caps packets, not bytes. aedes sets no maximum packet size and mqtt-packet permits a 256 MB remaining
length, so the byte bound is
maxInflightInbound × max frame size. A byte budget or a frame limit is a separatechange;
docs/Aedes.mdsays so rather than implying a memory bound the code doesn't deliver.Separately: growth across distinct client ids is a different axis that no per-client cap closes. Worth its own
advisory.
Tests
Seven cases in
test/qos2.js, written against what the review actually found rather than the happy path:maxInflightInbound: 0disables the capmaxInflightInboundfalls back to the default instead of disabling the capFull suite 327 pass / 0 fail, lint and tsd clean.
Cherry-picked from the advisory's private fork (
b490a43) ontomain, so the merge carries a subject thechangelog can render. Verified here against the released
aedes-persistence@11.0.0: 372 pass / 0 fail(6 platform skips), lint and tsd clean.