Skip to content

fix: bound the inbound QoS 2 receive window (GHSA-5jvp-3p2v-5qgf) - #1154

Merged
robertsLando merged 2 commits into
mainfrom
security/ghsa-5jvp-receive-window
Sep 16, 2026
Merged

robertsLando merged 2 commits into
mainfrom
security/ghsa-5jvp-receive-window

Conversation

@robertsLando

Copy link
Copy Markdown
Member

Fixes GHSA-5jvp-3p2v-5qgf. Reported by Team Atlanta.

Builds on the GHSA-p8r9 fix, already on main as c7acaa1 — the dependency is real, not packaging: see
"Why it needs p8r9". Also needs aedes-persistence 11.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 — queueLimit only guards the pre-CONNACK parser queue. Unbounded
attacker-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 incomingStorePacket and incremented
after. Deep review found it did not work, and I reproduced both failures:

1. The cap was bypassable by the exact flood it targets. enqueue in lib/client.js dispatches every packet
of 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 clientError raised.

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 exceeded and the cap holds.

Also fixed:

  • 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.
  • maxInflightInbound is clamped. -1, NaN, 1.5 or a non-numeric config previously took the limit > 0
    false branch and silently disabled the security control. They now fall back to the default; only an explicit 0
    opts out.
  • The error names the option and the limit, matching queueLimit's Client queue limit reached convention,
    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. 0 restores 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 separate
change; docs/Aedes.md says 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:

  • the cap holds against a 40-packet pipelined flood in one chunk (the bypass)
  • a DUP retransmission does not consume a second slot (the availability regression)
  • completing PUBREL frees the slot — a well-behaved client is never throttled
  • a duplicate PUBREL does not crash and does not free a slot twice
  • maxInflightInbound: 0 disables the cap
  • a bad maxInflightInbound falls back to the default instead of disabling the cap
  • the original boundary case

Full suite 327 pass / 0 fail, lint and tsd clean.


Cherry-picked from the advisory's private fork (b490a43) onto main, so the merge carries a subject the
changelog can render. Verified here against the released aedes-persistence@11.0.0: 372 pass / 0 fail
(6 platform skips), lint and tsd clean.

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

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.90%. Comparing base (c7acaa1) to head (dfac7d2).

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           
Flag Coverage Δ
unittests 99.90% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
@robertsLando
robertsLando merged commit e1a6067 into main Sep 16, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant