From 914ac8741fed9f02aded5a5d81c104739202c8e8 Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Mon, 6 Jul 2026 16:50:06 +0200 Subject: [PATCH 01/12] feat: MQTT 5.0 outbound (broker-assigned) Topic Alias (#840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a client advertises a Topic Alias Maximum > 0 in its CONNECT (§3.1.2.11.5), the broker now assigns aliases on outbound PUBLISH to save bandwidth (§3.3.2.3.4): the first PUBLISH on a topic carries the full topic name + a Topic Alias, and subsequent PUBLISHes on that topic carry an empty topic + the alias. Per connection, bounded by the client's advertised maximum; server MAY, so it is off unless the client opts in. - write.js: `withOutboundTopicAlias` rewrites a v5 PUBLISH just before serialize. It returns a shallow clone when aliasing (never mutates the caller's packet, which persistence holds by reference for QoS>0 and must keep its real topic for resend). Non-publish / non-v5 / non-advertising clients hit a cheap early guard. - connect.js: parse the client's topicAliasMaximum into `client._outboundTopicAliasMaximum`; init the per-connection alias Map only when > 0. - Strategy: assign 1..max in first-seen order, never evict; once full, later new topics send the full name (no alias). Tests: register-then-reuse, not-advertised (no aliasing), and full-table fallback. Docs: topicAliasMaximum note clarifying the two directions. Note: the alias map is bounded by min(clientMax, distinct topics delivered) — a client advertising 65535 could grow it accordingly (self-inflicted, per connection); a broker-side cap can be a follow-up if wanted. Co-Authored-By: Claude Opus 4.8 --- docs/Aedes.md | 2 +- lib/handlers/connect.js | 7 +++++ lib/write.js | 35 ++++++++++++++++++++++- test/mqtt5.js | 63 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/docs/Aedes.md b/docs/Aedes.md index e4451d4f..a80370cb 100644 --- a/docs/Aedes.md +++ b/docs/Aedes.md @@ -52,7 +52,7 @@ - `id` `` aedes broker unique identifier. __Default__: `uuidv4()` - `connectTimeout` `` maximum waiting time in milliseconds waiting for a [`CONNECT`][CONNECT] packet. __Default__: `30000` - `keepaliveLimit` `` maximum client keep alive time allowed, 0 means no limit. For MQTT 5.0 clients exceeding this, the broker sends a `Server Keep Alive` in the CONNACK and uses it instead of rejecting the connection. __Default__: `0` - - `topicAliasMaximum` `` MQTT 5.0 only. Maximum inbound Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. __Default__: `0` + - `topicAliasMaximum` `` MQTT 5.0 only. Maximum __inbound__ Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. __Default__: `0`. _(The reverse direction is automatic and needs no option: when a client advertises its own Topic Alias Maximum in CONNECT, the broker assigns aliases on __outbound__ PUBLISH to that client — full topic + alias on first use, empty topic + alias thereafter, per connection.)_ - `maximumPacketSize` `` MQTT 5.0 only. Maximum size in bytes of a packet the broker accepts, advertised in the CONNACK. Enforced: an oversized inbound frame is rejected as soon as its declared length is known — before the rest of its payload is buffered — with a `DISCONNECT` (reason code `0x95`, Packet too large) for connected v5 clients, or a dropped connection with a `connectionError`/`clientError` for pre-auth or v3/v4 clients. `0` means no limit. __Default__: `0` - `receiveMaximum` `` MQTT 5.0 only. Maximum number of in-flight QoS 1/2 PUBLISH packets advertised to the client in the CONNACK. __Advisory only__: the value is advertised but the broker does not currently enforce an inbound in-flight window (existing `drainTimeout` transport backpressure applies instead); enforcement is planned for a follow-up. `0` means it is not advertised (clients assume the protocol default of `65535`). __Default__: `0` - `sessionExpiryIntervalLimit` `` MQTT 5.0 only. Upper bound in seconds on the Session Expiry Interval a client may request. A larger requested value — including `0xFFFFFFFF` ("never expires") — is clamped to this, both in CONNECT and in a DISCONNECT that updates the interval. Bounds how long per-client session-expiry and will-delay timers (and their persisted session state) live, limiting memory accumulation from a single source cycling client identities. `0` means no cap. __Default__: `0` diff --git a/lib/handlers/connect.js b/lib/handlers/connect.js index 3e0c17da..0858fc90 100644 --- a/lib/handlers/connect.js +++ b/lib/handlers/connect.js @@ -303,6 +303,13 @@ function init (client, packet, done) { // (Consistent posture; enforcement is a planned follow-up.) client.maximumPacketSize = packet.properties?.maximumPacketSize // bytes, broker->client client.receiveMaximum = packet.properties?.receiveMaximum ?? RECEIVE_MAXIMUM_DEFAULT // in-flight QoS 1/2 + // [#840] MQTT 5.0 outbound Topic Alias: the client's Topic Alias Maximum is the + // highest alias value it will accept from the broker (§3.1.2.11.5). When > 0, + // the broker MAY alias outbound PUBLISH topics; track the per-connection map. + client._outboundTopicAliasMaximum = packet.protocolVersion === 5 ? (packet.properties?.topicAliasMaximum ?? 0) : 0 + if (client._outboundTopicAliasMaximum > 0) { + client._outboundTopicAliases = new Map() + } // MQTT 5.0 Request Problem Information [MQTT-3.1.2-29]: when false the broker // must not send a Reason String or User Properties on packets other than // PUBLISH/CONNACK/DISCONNECT. mqtt-packet decodes the byte property as a diff --git a/lib/write.js b/lib/write.js index fb36f412..24ea18dd 100644 --- a/lib/write.js +++ b/lib/write.js @@ -9,6 +9,38 @@ const WRITE_OPTS = { 5: Object.freeze({ protocolVersion: 5 }) } +// [#840] MQTT 5.0 outbound (broker-assigned) Topic Alias. When a client +// advertised a Topic Alias Maximum > 0 in its CONNECT, the broker MAY replace +// the topic of an outbound PUBLISH with a small integer alias to save bandwidth: +// the first PUBLISH on a topic carries the full topic name plus a Topic Alias +// (registering it for the connection); later PUBLISHes on that topic carry an +// empty topic and the alias. §3.3.2.3.4 +// +// Returns the object to serialize — a shallow clone when aliased, so the +// caller's packet (which persistence may hold by reference for QoS > 0, and +// which must keep its real topic for resend after reconnect) is never mutated. +function withOutboundTopicAlias (client, packet, version) { + const max = client._outboundTopicAliasMaximum + if (!max || version !== 5 || packet.cmd !== 'publish' || !packet.topic) { + return packet + } + const aliases = client._outboundTopicAliases + const known = aliases.get(packet.topic) + if (known !== undefined) { + // Registered topic: send an empty topic + the alias. + return { ...packet, topic: '', properties: { ...packet.properties, topicAlias: known } } + } + if (aliases.size >= max) { + // Alias table full — send the full topic name, no alias (never evict). + return packet + } + // New topic: assign the next alias (1..max) and send the full topic + alias, + // which registers the mapping on the client for subsequent PUBLISHes. + const alias = aliases.size + 1 + aliases.set(packet.topic, alias) + return { ...packet, properties: { ...packet.properties, topicAlias: alias } } +} + function write (client, packet, done, protocolVersion) { let error = null if (client.connecting || client.connected) { @@ -23,7 +55,8 @@ function write (client, packet, done, protocolVersion) { if (protocolVersion !== undefined) { version = (protocolVersion === 3 || protocolVersion === 5) ? protocolVersion : 4 } - const result = mqtt.writeToStream(packet, client.conn, WRITE_OPTS[version]) + const toWrite = withOutboundTopicAlias(client, packet, version) + const result = mqtt.writeToStream(toWrite, client.conn, WRITE_OPTS[version]) if (!result && !client.errored) { // Socket buffer is full - wait for drain client.waitForDrain(done) diff --git a/test/mqtt5.js b/test/mqtt5.js index 7ca9f7de..0ed79f46 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -141,6 +141,69 @@ test('MQTT 5.0 inbound topic alias is resolved and delivered with the real topic t.assert.equal(payload2.toString(), 'two') }) +test('MQTT 5.0 outbound Topic Alias: the broker aliases repeated topics to a v5 client', async (t) => { + t.plan(5) + const { connect } = await createServerAndConnect(t) + // The subscriber advertises a Topic Alias Maximum, so the broker may alias. + const sub = connect({ clientId: 'oalias-sub', properties: { topicAliasMaximum: 5 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('o/alias', { qos: 0 }) + + const pub = connect({ clientId: 'oalias-pub' }) + await once(pub, 'connect') + await pub.publishAsync('o/alias', 'one') + while (publishes.length < 1) await delay(5) + await pub.publishAsync('o/alias', 'two') + while (publishes.length < 2) await delay(5) + + // First delivery: full topic + a topic alias that registers the mapping. + t.assert.equal(publishes[0].topic, 'o/alias', 'first PUBLISH carries the full topic') + const alias = publishes[0].properties?.topicAlias + t.assert.ok(alias >= 1, 'first PUBLISH assigns a topic alias') + // Second delivery on the same topic: empty topic + the same alias. + t.assert.equal(publishes[1].topic, '', 'second PUBLISH omits the topic') + t.assert.equal(publishes[1].properties?.topicAlias, alias, 'second PUBLISH reuses the alias') + t.assert.equal(publishes[1].payload.toString(), 'two', 'payload still delivered') +}) + +test('MQTT 5.0 outbound Topic Alias: not used when the client did not advertise one', async (t) => { + t.plan(2) + const { connect } = await createServerAndConnect(t) + const sub = connect({ clientId: 'oalias-off' }) // no topicAliasMaximum ⇒ 0 + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('o/noalias', { qos: 0 }) + const pub = connect({ clientId: 'oalias-off-pub' }) + await once(pub, 'connect') + await pub.publishAsync('o/noalias', 'one') + await pub.publishAsync('o/noalias', 'two') + while (publishes.length < 2) await delay(5) + t.assert.equal(publishes[1].topic, 'o/noalias', 'full topic kept (no aliasing)') + t.assert.equal(publishes[1].properties?.topicAlias, undefined, 'no topic alias assigned') +}) + +test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full topic', async (t) => { + t.plan(3) + const { connect } = await createServerAndConnect(t) + const sub = connect({ clientId: 'oalias-full', properties: { topicAliasMaximum: 1 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('o/#', { qos: 0 }) + const pub = connect({ clientId: 'oalias-full-pub' }) + await once(pub, 'connect') + await pub.publishAsync('o/a', '1') + while (publishes.length < 1) await delay(5) + await pub.publishAsync('o/b', '2') // distinct topic, but the single alias slot is taken + while (publishes.length < 2) await delay(5) + t.assert.equal(publishes[0].properties?.topicAlias, 1, 'first topic gets alias 1') + t.assert.equal(publishes[1].topic, 'o/b', 'second topic sent with its full name (table full)') + t.assert.equal(publishes[1].properties?.topicAlias, undefined, 'no alias assigned once the table is full') +}) + test('MQTT 5.0 subscription identifier is echoed on matching publishes', async (t) => { t.plan(1) const { connect } = await createServerAndConnect(t) From 1a7fdabd32ec055231772cdf7daf68177d290b38 Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Mon, 13 Jul 2026 16:29:10 +0200 Subject: [PATCH 02/12] =?UTF-8?q?test/docs:=20address=20#1117=20review=20?= =?UTF-8?q?=E2=80=94=20per-connection=20alias=20reset=20test=20+=20cap=20f?= =?UTF-8?q?ollow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a reconnect test pinning the §3.3.2-11 invariant: outbound Topic Alias mappings must not survive a Network Connection. A fresh Client gets a fresh alias map, so the first PUBLISH after reconnect on a previously-aliased topic carries the full topic + a newly-assigned alias, never a bare alias. - Reference the tracked follow-up (#1124) for a broker-side outbound alias cap in the connect.js comment, per the review thread (deferred to keep this PR focused). Refs #840 Co-Authored-By: Claude Opus 4.8 --- lib/handlers/connect.js | 2 ++ test/mqtt5.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/lib/handlers/connect.js b/lib/handlers/connect.js index 0858fc90..d3e55365 100644 --- a/lib/handlers/connect.js +++ b/lib/handlers/connect.js @@ -306,6 +306,8 @@ function init (client, packet, done) { // [#840] MQTT 5.0 outbound Topic Alias: the client's Topic Alias Maximum is the // highest alias value it will accept from the broker (§3.1.2.11.5). When > 0, // the broker MAY alias outbound PUBLISH topics; track the per-connection map. + // The upper bound is client-advertised only; a broker-side cap for symmetry with + // the inbound `topicAliasMaximum` option is tracked in #1124. client._outboundTopicAliasMaximum = packet.protocolVersion === 5 ? (packet.properties?.topicAliasMaximum ?? 0) : 0 if (client._outboundTopicAliasMaximum > 0) { client._outboundTopicAliases = new Map() diff --git a/test/mqtt5.js b/test/mqtt5.js index 0ed79f46..396132ac 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -168,6 +168,45 @@ test('MQTT 5.0 outbound Topic Alias: the broker aliases repeated topics to a v5 t.assert.equal(publishes[1].payload.toString(), 'two', 'payload still delivered') }) +test('MQTT 5.0 outbound Topic Alias: mappings reset on reconnect, not carried across connections', async (t) => { + t.plan(3) + // §3.3.2-11: Topic Alias mappings must not survive a Network Connection. A fresh + // Client gets a fresh alias map, so the first PUBLISH after reconnect on a + // previously-aliased topic must carry the full topic + a newly-assigned alias — + // never an empty topic referencing an alias the new connection never sent. + const { connect } = await createServerAndConnect(t) + const opts = { clientId: 'oalias-reset', clean: true, reconnectPeriod: 0, properties: { topicAliasMaximum: 5 } } + + const sub1 = connect(opts) + await once(sub1, 'connect') + const pubs1 = [] + sub1.on('packetreceive', p => { if (p.cmd === 'publish') pubs1.push(p) }) + await sub1.subscribeAsync('o/reset', { qos: 0 }) + + const pub = connect({ clientId: 'oalias-reset-pub' }) + t.after(() => pub.end(true)) + await once(pub, 'connect') + await pub.publishAsync('o/reset', 'one') + await pub.publishAsync('o/reset', 'two') + while (pubs1.length < 2) await delay(5) + t.assert.equal(pubs1[1].topic, '', 'alias is established on the first connection (empty topic on reuse)') + + // Reconnect: a brand-new Client on the broker side, hence a new alias map. + const closed = new Promise(resolve => sub1.once('close', resolve)) + sub1.end(true) + await closed + const sub2 = connect(opts) + t.after(() => sub2.end(true)) + await once(sub2, 'connect') + const pubs2 = [] + sub2.on('packetreceive', p => { if (p.cmd === 'publish') pubs2.push(p) }) + await sub2.subscribeAsync('o/reset', { qos: 0 }) + await pub.publishAsync('o/reset', 'three') + while (pubs2.length < 1) await delay(5) + t.assert.equal(pubs2[0].topic, 'o/reset', 'first PUBLISH after reconnect carries the full topic (map reset)') + t.assert.ok(pubs2[0].properties?.topicAlias >= 1, 'and a freshly-assigned alias') +}) + test('MQTT 5.0 outbound Topic Alias: not used when the client did not advertise one', async (t) => { t.plan(2) const { connect } = await createServerAndConnect(t) From 52a8a384d7d3d7d955b49f856b68bc69c3a2763c Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Tue, 21 Jul 2026 13:14:50 +0200 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20address=20#1117=20round-2=20review?= =?UTF-8?q?=20=E2=80=94=20dup-property=20crash,=20alias=20cap,=20test=20co?= =?UTF-8?q?verage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker (connect.js): a duplicated Topic Alias Maximum in CONNECT decodes to an array (a §3.1.2.11.5 Protocol Error) that slipped between two type-inconsistent guards and crashed the connection on first delivery. Honor only a numeric value; an array now disables outbound aliasing for the connection instead of crashing. Major (write.js/constants.js): the outbound alias table was unbounded and sized by the client (measured ~19 MB/conn for 10k topics). Clamp the effective per-connection maximum to min(clientAdvertised, OUTBOUND_TOPIC_ALIAS_LIMIT=64); a configurable cap stays tracked in #1124. Major (run_compat.py / mqtt-compat.yml): drop test_server_topic_alias from EXPECTED_GAPS — this PR implements it and the compat run now unexpectedly passes. Make the unexpected-pass guard fail the job (SystemExit) so a stale gap list can't reach a green check again. Major (tests): add the two missing coverage lanes — an aliased QoS 1 message left un-acked and resent after reconnect carries the full topic (QoS>0 path), and a multi-subscriber fanout (two v5 subs with independent per-connection alias sequences; a v3/v4 sub that never receives a Topic Alias). Minors: reject non-string (Buffer) topics from aliasing; note the alias-commit-before-write ordering for the planned maximumPacketSize follow-up; rename the inbound map _topicAliases -> _inboundTopicAliases; add a waitFor helper replacing the busy-wait polls; wait on broker-side deregistration on reconnect; strictEqual for the aliasing-off assertions; drop redundant client.end() calls; move the outbound-alias behaviour to a connackSent callout in docs; README nit. Refs #840 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/mqtt-compat.yml | 5 +- docs/Aedes.md | 4 +- lib/client.js | 2 +- lib/constants.js | 8 ++ lib/handlers/connect.js | 14 ++- lib/handlers/publish.js | 4 +- lib/write.js | 14 ++- test/mqtt5.js | 192 +++++++++++++++++++++++++++--- tools/mqtt-compat/README.md | 5 +- tools/mqtt-compat/run_compat.py | 3 - 10 files changed, 222 insertions(+), 29 deletions(-) diff --git a/.github/workflows/mqtt-compat.yml b/.github/workflows/mqtt-compat.yml index 021bc1e8..888274eb 100644 --- a/.github/workflows/mqtt-compat.yml +++ b/.github/workflows/mqtt-compat.yml @@ -112,10 +112,13 @@ jobs: if t.get("unexpected_pass")] if xpass: names = ", ".join(xpass) - print(f"::warning title=MQTT compat: update EXPECTED_GAPS::" + print(f"::error title=MQTT compat: update EXPECTED_GAPS::" f"{len(xpass)} expected-gap test(s) now pass: {names}. " f"Remove them from EXPECTED_GAPS in " f"tools/mqtt-compat/run_compat.py.") + # Fail the job: a stale gap list (a now-implemented feature still + # marked as an expected gap) must not slip through on a green check. + raise SystemExit(1) PY - name: Job summary diff --git a/docs/Aedes.md b/docs/Aedes.md index a80370cb..e0d7d823 100644 --- a/docs/Aedes.md +++ b/docs/Aedes.md @@ -52,7 +52,7 @@ - `id` `` aedes broker unique identifier. __Default__: `uuidv4()` - `connectTimeout` `` maximum waiting time in milliseconds waiting for a [`CONNECT`][CONNECT] packet. __Default__: `30000` - `keepaliveLimit` `` maximum client keep alive time allowed, 0 means no limit. For MQTT 5.0 clients exceeding this, the broker sends a `Server Keep Alive` in the CONNACK and uses it instead of rejecting the connection. __Default__: `0` - - `topicAliasMaximum` `` MQTT 5.0 only. Maximum __inbound__ Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. __Default__: `0`. _(The reverse direction is automatic and needs no option: when a client advertises its own Topic Alias Maximum in CONNECT, the broker assigns aliases on __outbound__ PUBLISH to that client — full topic + alias on first use, empty topic + alias thereafter, per connection.)_ + - `topicAliasMaximum` `` MQTT 5.0 only. Maximum __inbound__ Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. __Default__: `0`. (Outbound aliasing — the broker aliasing topics on PUBLISHes it sends — is automatic and needs no option; see the callout under [`Event: connackSent`](#event-connacksent).) - `maximumPacketSize` `` MQTT 5.0 only. Maximum size in bytes of a packet the broker accepts, advertised in the CONNACK. Enforced: an oversized inbound frame is rejected as soon as its declared length is known — before the rest of its payload is buffered — with a `DISCONNECT` (reason code `0x95`, Packet too large) for connected v5 clients, or a dropped connection with a `connectionError`/`clientError` for pre-auth or v3/v4 clients. `0` means no limit. __Default__: `0` - `receiveMaximum` `` MQTT 5.0 only. Maximum number of in-flight QoS 1/2 PUBLISH packets advertised to the client in the CONNACK. __Advisory only__: the value is advertised but the broker does not currently enforce an inbound in-flight window (existing `drainTimeout` transport backpressure applies instead); enforcement is planned for a follow-up. `0` means it is not advertised (clients assume the protocol default of `65535`). __Default__: `0` - `sessionExpiryIntervalLimit` `` MQTT 5.0 only. Upper bound in seconds on the Session Expiry Interval a client may request. A larger requested value — including `0xFFFFFFFF` ("never expires") — is clamped to this, both in CONNECT and in a DISCONNECT that updates the interval. Bounds how long per-client session-expiry and will-delay timers (and their persisted session state) live, limiting memory accumulation from a single source cycling client identities. `0` means no cap. __Default__: `0` @@ -233,6 +233,8 @@ Emitted when server sends an acknowledge to `client`. Please refer to the MQTT s For MQTT 3.1/3.1.1 the packet carries a `returnCode` (`0` = success). For MQTT 5.0 it instead carries a `reasonCode` (`0x00` = success; the v3/v4 return codes map to the equivalent v5 reason codes) and may carry a `properties` object advertising negotiated capabilities (e.g. `topicAliasMaximum`, `maximumPacketSize`, `receiveMaximum`, `serverKeepAlive`, `assignedClientIdentifier`, `sharedSubscriptionAvailable`). +> __MQTT 5.0 outbound Topic Alias:__ automatic, no option. When a v5 client advertises its own `Topic Alias Maximum` in CONNECT, the broker assigns Topic Aliases on the PUBLISHes it sends to that client — full topic + alias on first use of a topic, empty topic + the same alias thereafter — to save bandwidth. Mappings are per Network Connection (reset on reconnect, never carried across, per [MQTT-3.3.2-11]) and the per-connection table never evicts. The number of aliases assigned is `min(client's advertised maximum, 64)`; a configurable broker-side cap is tracked in [#1124](https://github.com/moscajs/aedes/issues/1124). + ## Event: closed Emitted when server is closed. diff --git a/lib/client.js b/lib/client.js index 815db920..fec8f8bd 100644 --- a/lib/client.js +++ b/lib/client.js @@ -36,7 +36,7 @@ class Client { // MQTT 5.0 inbound Topic Alias map (alias number -> topic name), scoped // to this connection. Populated as the client registers aliases. - this._topicAliases = new Map() + this._inboundTopicAliases = new Map() this.broker = broker this.conn = conn diff --git a/lib/constants.js b/lib/constants.js index 329b5bef..477d5ab8 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -12,6 +12,14 @@ export const SUBSCRIPTION_IDENTIFIER_MAX = 268435455 // [MQTT-3.1.2.11.3] Default Receive Maximum when a client omits the property. export const RECEIVE_MAXIMUM_DEFAULT = 65535 +// [#840] Broker-side ceiling on how many outbound Topic Aliases the broker will +// assign per connection, regardless of the (client-advertised) Topic Alias +// Maximum. The table never evicts and is keyed by full topic strings, so an +// unbounded client-chosen size is a per-connection memory-amplification vector; +// 64 covers realistic topic sets. A configurable broker option is tracked in +// #1124. +export const OUTBOUND_TOPIC_ALIAS_LIMIT = 64 + // MQTT 5.0 [MQTT-2.4]: reason codes >= 0x80 indicate failure; < 0x80 are success // (0x00) / normal outcomes. Used to classify SUBACK grants and inbound PUBREC. export const REASON_CODE_ERROR_THRESHOLD = 0x80 diff --git a/lib/handlers/connect.js b/lib/handlers/connect.js index d3e55365..73e4cbf7 100644 --- a/lib/handlers/connect.js +++ b/lib/handlers/connect.js @@ -4,7 +4,7 @@ import write from '../write.js' import QoSPacket from '../qos-packet.js' import { runSeries, through } from '../utils.js' import handleSubscribe from './subscribe.js' -import { SESSION_NEVER_EXPIRES, RECEIVE_MAXIMUM_DEFAULT, ReasonCodes } from '../constants.js' +import { SESSION_NEVER_EXPIRES, RECEIVE_MAXIMUM_DEFAULT, OUTBOUND_TOPIC_ALIAS_LIMIT, ReasonCodes } from '../constants.js' // Resolves the effective Session Expiry Interval (in seconds) from a CONNECT. // For MQTT 5.0 it comes from the property (default 0 = ends with the network @@ -306,9 +306,15 @@ function init (client, packet, done) { // [#840] MQTT 5.0 outbound Topic Alias: the client's Topic Alias Maximum is the // highest alias value it will accept from the broker (§3.1.2.11.5). When > 0, // the broker MAY alias outbound PUBLISH topics; track the per-connection map. - // The upper bound is client-advertised only; a broker-side cap for symmetry with - // the inbound `topicAliasMaximum` option is tracked in #1124. - client._outboundTopicAliasMaximum = packet.protocolVersion === 5 ? (packet.properties?.topicAliasMaximum ?? 0) : 0 + // Only a numeric value is honored: mqtt-packet decodes a duplicated property + // into an array (a Protocol Error per §3.1.2.11.5), which must not reach the + // write path as a bogus max. The effective size is clamped to a broker-side + // ceiling (never-evicting, client-keyed table — see OUTBOUND_TOPIC_ALIAS_LIMIT); + // a configurable cap is tracked in #1124. + const advertisedTopicAliasMax = packet.properties?.topicAliasMaximum + client._outboundTopicAliasMaximum = packet.protocolVersion === 5 && typeof advertisedTopicAliasMax === 'number' + ? Math.min(advertisedTopicAliasMax, OUTBOUND_TOPIC_ALIAS_LIMIT) + : 0 if (client._outboundTopicAliasMaximum > 0) { client._outboundTopicAliases = new Map() } diff --git a/lib/handlers/publish.js b/lib/handlers/publish.js index 85b4eab5..f8b625b7 100644 --- a/lib/handlers/publish.js +++ b/lib/handlers/publish.js @@ -159,10 +159,10 @@ function resolveTopicAlias (client, packet) { // store a wildcard/invalid topic in the connection's alias map. An invalid // topic still falls through to handlePublish's checks, which reject it. if (packet.topic.indexOf('#') === -1 && packet.topic.indexOf('+') === -1) { - client._topicAliases.set(alias, packet.topic) + client._inboundTopicAliases.set(alias, packet.topic) } } else { - const topic = client._topicAliases.get(alias) + const topic = client._inboundTopicAliases.get(alias) if (topic === undefined) { return new Error(`unknown topic alias ${alias}`) } diff --git a/lib/write.js b/lib/write.js index 24ea18dd..292f3253 100644 --- a/lib/write.js +++ b/lib/write.js @@ -21,7 +21,11 @@ const WRITE_OPTS = { // which must keep its real topic for resend after reconnect) is never mutated. function withOutboundTopicAlias (client, packet, version) { const max = client._outboundTopicAliasMaximum - if (!max || version !== 5 || packet.cmd !== 'publish' || !packet.topic) { + // Only alias non-empty *string* topics: a Buffer topic (reachable via + // client.publish(), which skips topic validation) would key the Map by object + // identity, so an equal-but-distinct Buffer never matches — burning a fresh + // alias slot per publish until the table is permanently full. + if (!max || version !== 5 || packet.cmd !== 'publish' || typeof packet.topic !== 'string' || packet.topic === '') { return packet } const aliases = client._outboundTopicAliases @@ -36,6 +40,14 @@ function withOutboundTopicAlias (client, packet, version) { } // New topic: assign the next alias (1..max) and send the full topic + alias, // which registers the mapping on the client for subsequent PUBLISHes. + // + // The mapping is committed before writeToStream runs (see write() below). Safe + // today: a serialize/flush failure calls _onError, which destroys the socket + // and discards this per-connection map, so no later aliased send references an + // alias the client never saw. It would stop being safe if a write path ever + // *drops* a PUBLISH without tearing the connection down (e.g. the planned + // outbound maximumPacketSize enforcement) — commit only after a successful + // write if that lands. [#840] const alias = aliases.size + 1 aliases.set(packet.topic, alias) return { ...packet, properties: { ...packet.properties, topicAlias: alias } } diff --git a/test/mqtt5.js b/test/mqtt5.js index 396132ac..f99ed26b 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -45,6 +45,16 @@ async function createServerAndConnect (t, { brokerOptions } = {}) { return { broker, server, port, connect } } +// Poll `pred` until it is truthy, throwing after `ms` so a broker that stops +// delivering surfaces as a clear assertion instead of a 3-minute test timeout. +async function waitFor (pred, msg, ms = 2000) { + const deadline = Date.now() + ms + while (!pred()) { + if (Date.now() > deadline) throw new Error(`waitFor timed out: ${msg}`) + await delay(5) + } +} + test('MQTT 5.0 client connects and receives a v5 CONNACK', async (t) => { t.plan(2) const { connect } = await createServerAndConnect(t) @@ -154,9 +164,9 @@ test('MQTT 5.0 outbound Topic Alias: the broker aliases repeated topics to a v5 const pub = connect({ clientId: 'oalias-pub' }) await once(pub, 'connect') await pub.publishAsync('o/alias', 'one') - while (publishes.length < 1) await delay(5) + await waitFor(() => publishes.length >= 1, 'first delivery') await pub.publishAsync('o/alias', 'two') - while (publishes.length < 2) await delay(5) + await waitFor(() => publishes.length >= 2, 'second delivery') // First delivery: full topic + a topic alias that registers the mapping. t.assert.equal(publishes[0].topic, 'o/alias', 'first PUBLISH carries the full topic') @@ -174,7 +184,7 @@ test('MQTT 5.0 outbound Topic Alias: mappings reset on reconnect, not carried ac // Client gets a fresh alias map, so the first PUBLISH after reconnect on a // previously-aliased topic must carry the full topic + a newly-assigned alias — // never an empty topic referencing an alias the new connection never sent. - const { connect } = await createServerAndConnect(t) + const { broker, connect } = await createServerAndConnect(t) const opts = { clientId: 'oalias-reset', clean: true, reconnectPeriod: 0, properties: { topicAliasMaximum: 5 } } const sub1 = connect(opts) @@ -184,25 +194,24 @@ test('MQTT 5.0 outbound Topic Alias: mappings reset on reconnect, not carried ac await sub1.subscribeAsync('o/reset', { qos: 0 }) const pub = connect({ clientId: 'oalias-reset-pub' }) - t.after(() => pub.end(true)) await once(pub, 'connect') await pub.publishAsync('o/reset', 'one') await pub.publishAsync('o/reset', 'two') - while (pubs1.length < 2) await delay(5) + await waitFor(() => pubs1.length >= 2, 'first-connection deliveries') t.assert.equal(pubs1[1].topic, '', 'alias is established on the first connection (empty topic on reuse)') // Reconnect: a brand-new Client on the broker side, hence a new alias map. - const closed = new Promise(resolve => sub1.once('close', resolve)) + // Wait for broker-side deregistration (not just the client 'close') so sub2 + // does not race the old Client through the takeover path. sub1.end(true) - await closed + await waitFor(() => !broker.clients['oalias-reset'], 'old Client deregistered') const sub2 = connect(opts) - t.after(() => sub2.end(true)) await once(sub2, 'connect') const pubs2 = [] sub2.on('packetreceive', p => { if (p.cmd === 'publish') pubs2.push(p) }) await sub2.subscribeAsync('o/reset', { qos: 0 }) await pub.publishAsync('o/reset', 'three') - while (pubs2.length < 1) await delay(5) + await waitFor(() => pubs2.length >= 1, 'delivery after reconnect') t.assert.equal(pubs2[0].topic, 'o/reset', 'first PUBLISH after reconnect carries the full topic (map reset)') t.assert.ok(pubs2[0].properties?.topicAlias >= 1, 'and a freshly-assigned alias') }) @@ -219,9 +228,9 @@ test('MQTT 5.0 outbound Topic Alias: not used when the client did not advertise await once(pub, 'connect') await pub.publishAsync('o/noalias', 'one') await pub.publishAsync('o/noalias', 'two') - while (publishes.length < 2) await delay(5) + await waitFor(() => publishes.length >= 2, 'both deliveries') t.assert.equal(publishes[1].topic, 'o/noalias', 'full topic kept (no aliasing)') - t.assert.equal(publishes[1].properties?.topicAlias, undefined, 'no topic alias assigned') + t.assert.strictEqual(publishes[1].properties?.topicAlias, undefined, 'no topic alias assigned') }) test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full topic', async (t) => { @@ -235,12 +244,167 @@ test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full t const pub = connect({ clientId: 'oalias-full-pub' }) await once(pub, 'connect') await pub.publishAsync('o/a', '1') - while (publishes.length < 1) await delay(5) + await waitFor(() => publishes.length >= 1, 'first delivery') await pub.publishAsync('o/b', '2') // distinct topic, but the single alias slot is taken - while (publishes.length < 2) await delay(5) + await waitFor(() => publishes.length >= 2, 'second delivery') t.assert.equal(publishes[0].properties?.topicAlias, 1, 'first topic gets alias 1') t.assert.equal(publishes[1].topic, 'o/b', 'second topic sent with its full name (table full)') - t.assert.equal(publishes[1].properties?.topicAlias, undefined, 'no alias assigned once the table is full') + t.assert.strictEqual(publishes[1].properties?.topicAlias, undefined, 'no alias assigned once the table is full') +}) + +test('MQTT 5.0 outbound Topic Alias: an aliased QoS 1 message keeps its topic when resent after reconnect', async (t) => { + t.plan(3) + // write.js clones the packet when aliasing precisely because persistence holds + // the QoS > 0 packet by reference and must keep its real topic for resend + // (client.js outgoingUpdate stores the pre-clone object). Pin it: deliver an + // *aliased* message (sent as empty-topic), leave it un-acked, reconnect, and + // assert the resent PUBLISH carries the full topic — an in-place mutation would + // have corrupted the stored packet to an empty topic. A raw subscriber gives us + // control over PUBACK. §3.3.2-11 / write.js:19-21. + const { broker, port, connect } = await createServerAndConnect(t) + const connectSub = () => generate({ + cmd: 'connect', + protocolVersion: 5, + clientId: 'oalias-q1', + clean: false, + keepalive: 0, + properties: { topicAliasMaximum: 5, sessionExpiryInterval: 60 } + }, { protocolVersion: 5 }) + + // First connection: subscribe, receive two deliveries (second is aliased), ACK + // only the first, then drop the socket leaving the aliased one un-acked. + const raw1 = createConnection(port, 'localhost') + raw1.on('error', () => {}) + const parser1 = createParser({ protocolVersion: 5 }) + const rx1 = [] + parser1.on('packet', p => { + rx1.push(p) + if (p.cmd === 'connack') { + raw1.write(generate({ cmd: 'subscribe', messageId: 1, subscriptions: [{ topic: 'o/q1', qos: 1 }] }, { protocolVersion: 5 })) + } + }) + raw1.on('data', d => parser1.parse(d)) + raw1.write(connectSub()) + await waitFor(() => rx1.some(p => p.cmd === 'suback'), 'subscribed') + + const pub = connect({ clientId: 'oalias-q1-pub' }) + await once(pub, 'connect') + await pub.publishAsync('o/q1', 'one', { qos: 1 }) + await waitFor(() => rx1.filter(p => p.cmd === 'publish').length >= 1, 'first delivery') + // ACK the first (full-topic) delivery so only the aliased one is left pending. + const first = rx1.find(p => p.cmd === 'publish') + raw1.write(generate({ cmd: 'puback', messageId: first.messageId }, { protocolVersion: 5 })) + await pub.publishAsync('o/q1', 'two', { qos: 1 }) + await waitFor(() => rx1.filter(p => p.cmd === 'publish').length >= 2, 'aliased delivery') + const second = rx1.filter(p => p.cmd === 'publish')[1] + t.assert.equal(second.topic, '', 'the second delivery was aliased (empty topic on the wire)') + t.assert.equal(second.payload.toString(), 'two', 'payload delivered') + + // Drop the socket with 'two' un-acked; wait for broker-side teardown. + raw1.destroy() + await waitFor(() => !broker.clients['oalias-q1'], 'old Client deregistered') + + // Reconnect the persistent session: the un-acked 'two' is resent on a fresh + // Client (fresh alias map), so it must carry the full topic — proving the + // stored packet was never mutated to the empty topic it went out as. + const raw2 = createConnection(port, 'localhost') + t.after(() => raw2.destroy()) + raw2.on('error', () => {}) + const parser2 = createParser({ protocolVersion: 5 }) + const rx2 = [] + parser2.on('packet', p => rx2.push(p)) + raw2.on('data', d => parser2.parse(d)) + raw2.write(connectSub()) + await waitFor(() => rx2.some(p => p.cmd === 'publish' && p.payload.toString() === 'two'), 'un-acked message resent') + const resent = rx2.find(p => p.cmd === 'publish' && p.payload.toString() === 'two') + t.assert.equal(resent.topic, 'o/q1', 'resent QoS 1 PUBLISH carries the full topic (stored packet not mutated)') +}) + +test('MQTT 5.0 outbound Topic Alias: independent per-subscriber sequences on fanout; v3/v4 subscribers get none', async (t) => { + t.plan(8) + // A single publish fans out to per-subscriber Packets that reference-share one + // `properties` object (aedes-packet), so aliasing must spread (clone) rather + // than mutate. Also: a v3/v4 subscriber must never receive a Topic Alias. + const { port, connect } = await createServerAndConnect(t) + const collect = (client) => { + const pubs = [] + client.on('packetreceive', p => { if (p.cmd === 'publish') pubs.push(p) }) + return pubs + } + + const subA = connect({ clientId: 'fan-a', properties: { topicAliasMaximum: 5 } }) + const subB = connect({ clientId: 'fan-b', properties: { topicAliasMaximum: 5 } }) + // A v3.1.1 subscriber on the same broker — must never see a Topic Alias. + const subV4 = mqtt.connect({ port, host: 'localhost', protocolVersion: 4, clientId: 'fan-v4', reconnectPeriod: 0 }) + t.after(() => subV4.end(true)) + await Promise.all([once(subA, 'connect'), once(subB, 'connect'), once(subV4, 'connect')]) + const pubsA = collect(subA) + const pubsB = collect(subB) + const pubsV4 = collect(subV4) + await Promise.all([ + subA.subscribeAsync('o/fan', { qos: 0 }), + subB.subscribeAsync('o/fan', { qos: 0 }), + subV4.subscribeAsync('o/fan', { qos: 0 }) + ]) + + const pub = connect({ clientId: 'fan-pub' }) + await once(pub, 'connect') + await pub.publishAsync('o/fan', 'one') + await pub.publishAsync('o/fan', 'two') + await waitFor(() => pubsA.length >= 2 && pubsB.length >= 2 && pubsV4.length >= 2, 'all subscribers received both') + + // Each v5 subscriber gets its OWN alias sequence: full topic + alias, then empty. + t.assert.equal(pubsA[0].topic, 'o/fan', 'sub A: first carries the full topic') + t.assert.ok(pubsA[0].properties?.topicAlias >= 1, 'sub A: first assigns an alias') + t.assert.equal(pubsA[1].topic, '', 'sub A: second reuses the alias (empty topic)') + t.assert.equal(pubsB[0].topic, 'o/fan', 'sub B: first carries the full topic independently') + t.assert.equal(pubsB[1].topic, '', 'sub B: second reuses its own alias') + t.assert.equal(pubsA[1].properties?.topicAlias, pubsB[1].properties?.topicAlias, 'per-connection aliases (both 1) — spread, not shared/mutated') + // The v3/v4 subscriber must get the full topic on BOTH deliveries and no alias. + t.assert.equal(pubsV4[1].topic, 'o/fan', 'v4 sub: full topic on every delivery') + t.assert.strictEqual(pubsV4[1].properties?.topicAlias, undefined, 'v4 sub: never receives a Topic Alias') +}) + +test('MQTT 5.0 outbound Topic Alias: a duplicated Topic Alias Maximum in CONNECT does not crash delivery', async (t) => { + t.plan(2) + // §3.1.2.11.5: a repeated Topic Alias Maximum is a Protocol Error; mqtt-packet + // decodes it to an array. That array must not reach the write path as a bogus + // max (it would slip past two type-inconsistent guards and throw on the first + // delivery). We disable outbound aliasing for the connection rather than crash. + const { port, connect } = await createServerAndConnect(t) + const raw = createConnection(port, 'localhost') + t.after(() => raw.destroy()) + raw.on('error', () => {}) + const parser = createParser({ protocolVersion: 5 }) + const received = [] + let onConnack, onSuback + const gotConnack = new Promise(resolve => { onConnack = resolve }) + const gotSuback = new Promise(resolve => { onSuback = resolve }) + parser.on('packet', p => { + received.push(p) + if (p.cmd === 'connack') onConnack(p) + else if (p.cmd === 'suback') onSuback() + }) + raw.on('data', d => parser.parse(d)) + raw.write(generate({ + cmd: 'connect', + protocolVersion: 5, + clientId: 'oalias-dup', + clean: true, + keepalive: 0, + properties: { topicAliasMaximum: [5, 5] } + }, { protocolVersion: 5 })) + const connack = await gotConnack + t.assert.equal(connack.reasonCode, 0, 'connects despite the duplicated property') + raw.write(generate({ cmd: 'subscribe', messageId: 1, subscriptions: [{ topic: 'o/dup', qos: 0 }] }, { protocolVersion: 5 })) + await gotSuback + + const pub = connect({ clientId: 'oalias-dup-pub' }) + await once(pub, 'connect') + await pub.publishAsync('o/dup', 'hello') + await waitFor(() => received.some(p => p.cmd === 'publish'), 'delivery arrives without crashing') + const delivered = received.find(p => p.cmd === 'publish') + t.assert.equal(delivered.topic, 'o/dup', 'delivered with the full topic (aliasing disabled for the bad max)') }) test('MQTT 5.0 subscription identifier is echoed on matching publishes', async (t) => { diff --git a/tools/mqtt-compat/README.md b/tools/mqtt-compat/README.md index 604c1d54..c28d0431 100644 --- a/tools/mqtt-compat/README.md +++ b/tools/mqtt-compat/README.md @@ -83,8 +83,9 @@ The percentage is **"% of the Paho functional suite that passes"**, not "% MQTT-5.0-compliant". The Paho suite is a third-party **happy-path functional** test: it exercises the user-facing v5 features well (session expiry, will + will delay, message expiry, RH/RAP/NL, request/response, payload format, assigned client -id, server keep alive, inbound topic aliases, max-packet-size rejection, PUBLISH -user properties), but it touches only 5 reason codes and deliberately omits large +id, server keep alive, inbound and broker-assigned outbound topic aliases, +max-packet-size rejection, PUBLISH user properties), but it touches only 5 reason +codes and deliberately omits large parts of the spec: - **Error / negative paths** — most of aedes's reason codes (`lib/constants.js`): diff --git a/tools/mqtt-compat/run_compat.py b/tools/mqtt-compat/run_compat.py index d6982924..2eb6ec1a 100644 --- a/tools/mqtt-compat/run_compat.py +++ b/tools/mqtt-compat/run_compat.py @@ -49,9 +49,6 @@ # below (skipped before the gap logic runs), so a duplicate entry here would # be unreachable. The receiveMaximum gap it probes is tracked via # test_flow_control1 above. - "test_server_topic_alias": - "broker-assigned (outbound) topic aliases are not implemented; inbound " - "aliases work (#840 — spec-optional/MAY, deferred per #821)", "test_subscribe_identifiers": "a delivery matching multiple overlapping subscriptions echoes only one " "Subscription Identifier (#828 [MQTT-3.3.4-4] — deferred per #821)", From 8cb493c42ded95b7ea2a927e1e99101781c135cf Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Wed, 22 Jul 2026 08:58:17 +0200 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20address=20#1117=20round-2=20review?= =?UTF-8?q?=20=E2=80=94=20close=20alias-max=20class=20+=20a=20discriminati?= =?UTF-8?q?ng=20clone=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - connect.js: gate the outbound Topic Alias Maximum on `> 0` (the same predicate write.js uses via `!max`) instead of `typeof === 'number'`, so a negative value — reachable when a preConnect hook mutates the packet — yields 0 at BOTH sites and can't diverge into a bogus-max crash. Added a preConnect-negative regression test. - tests: rewrite the QoS 1 clone test so it actually pins the clone. The old one passed even against an in-place-mutation variant (QoSPacket copies topic by value). The new one exploits that aedes-packet reference-shares `properties`: publisher sends a User Property, an aliased QoS 1 message is left un-acked, the session reconnects advertising NO Topic Alias Maximum, and the resent PUBLISH must carry no stale topicAlias. Verified it fails on the mutating variant. - mqtt-compat.yml: rename the step "Warn on unexpected passes" -> "Fail on unexpected passes" (it now SystemExits). - Opened #1131 for the general duplicated-CONNECT-property validation (separate, base-branch change per review). Refs #840 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/mqtt-compat.yml | 2 +- lib/handlers/connect.js | 14 +++--- test/mqtt5.js | 75 ++++++++++++++++++++++--------- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/.github/workflows/mqtt-compat.yml b/.github/workflows/mqtt-compat.yml index 888274eb..1f64d138 100644 --- a/.github/workflows/mqtt-compat.yml +++ b/.github/workflows/mqtt-compat.yml @@ -100,7 +100,7 @@ jobs: if: always() run: kill "${BROKER_PID}" 2>/dev/null || true - - name: Warn on unexpected passes + - name: Fail on unexpected passes if: always() run: | [ -f result.json ] || exit 0 diff --git a/lib/handlers/connect.js b/lib/handlers/connect.js index 73e4cbf7..c414c832 100644 --- a/lib/handlers/connect.js +++ b/lib/handlers/connect.js @@ -306,13 +306,15 @@ function init (client, packet, done) { // [#840] MQTT 5.0 outbound Topic Alias: the client's Topic Alias Maximum is the // highest alias value it will accept from the broker (§3.1.2.11.5). When > 0, // the broker MAY alias outbound PUBLISH topics; track the per-connection map. - // Only a numeric value is honored: mqtt-packet decodes a duplicated property - // into an array (a Protocol Error per §3.1.2.11.5), which must not reach the - // write path as a bogus max. The effective size is clamped to a broker-side - // ceiling (never-evicting, client-keyed table — see OUTBOUND_TOPIC_ALIAS_LIMIT); - // a configurable cap is tracked in #1124. + // Only a positive number is honored. mqtt-packet decodes a duplicated property + // into an array (a Protocol Error per §3.1.2.11.5); a preConnect hook could also + // set a negative value on the mutable packet. Gate on `> 0` — the same predicate + // write.js uses (`!max`) — so a non-positive/array value yields 0 at BOTH sites + // and the guards can't diverge into a bogus-max crash. The effective size is + // clamped to a broker-side ceiling (never-evicting, client-keyed table — see + // OUTBOUND_TOPIC_ALIAS_LIMIT); a configurable cap is tracked in #1124. const advertisedTopicAliasMax = packet.properties?.topicAliasMaximum - client._outboundTopicAliasMaximum = packet.protocolVersion === 5 && typeof advertisedTopicAliasMax === 'number' + client._outboundTopicAliasMaximum = packet.protocolVersion === 5 && advertisedTopicAliasMax > 0 ? Math.min(advertisedTopicAliasMax, OUTBOUND_TOPIC_ALIAS_LIMIT) : 0 if (client._outboundTopicAliasMaximum > 0) { diff --git a/test/mqtt5.js b/test/mqtt5.js index f99ed26b..b797d576 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -252,27 +252,32 @@ test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full t t.assert.strictEqual(publishes[1].properties?.topicAlias, undefined, 'no alias assigned once the table is full') }) -test('MQTT 5.0 outbound Topic Alias: an aliased QoS 1 message keeps its topic when resent after reconnect', async (t) => { +test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not poison the stored packet', async (t) => { t.plan(3) - // write.js clones the packet when aliasing precisely because persistence holds - // the QoS > 0 packet by reference and must keep its real topic for resend - // (client.js outgoingUpdate stores the pre-clone object). Pin it: deliver an - // *aliased* message (sent as empty-topic), leave it un-acked, reconnect, and - // assert the resent PUBLISH carries the full topic — an in-place mutation would - // have corrupted the stored packet to an empty topic. A raw subscriber gives us - // control over PUBACK. §3.3.2-11 / write.js:19-21. + // write.js SPREADS (shallow-clones) rather than mutating. aedes-packet + // reference-shares the `properties` object between the delivered QoSPacket and + // the persistence-stored packet, so setting `properties.topicAlias` in place on + // an aliased send would poison the stored packet. Pin it end-to-end: establish + // an alias, leave the aliased QoS 1 message un-acked, reconnect advertising NO + // Topic Alias Maximum (aliasing off), and assert the resent PUBLISH carries no + // topicAlias — an in-place mutation would resend a stale alias the reconnected + // client never agreed to (a Protocol Error). The publisher sends a User Property + // so the shared `properties` object exists. §3.3.2-11 / write.js:19-21. const { broker, port, connect } = await createServerAndConnect(t) - const connectSub = () => generate({ + const connectSub = (advertiseAlias) => generate({ cmd: 'connect', protocolVersion: 5, clientId: 'oalias-q1', clean: false, keepalive: 0, - properties: { topicAliasMaximum: 5, sessionExpiryInterval: 60 } + properties: advertiseAlias + ? { topicAliasMaximum: 5, sessionExpiryInterval: 60 } + : { sessionExpiryInterval: 60 } }, { protocolVersion: 5 }) - // First connection: subscribe, receive two deliveries (second is aliased), ACK - // only the first, then drop the socket leaving the aliased one un-acked. + // First connection: advertise a Topic Alias Maximum, subscribe, receive two + // deliveries (second is aliased), ACK only the first, then drop the socket + // leaving the aliased one un-acked. const raw1 = createConnection(port, 'localhost') raw1.on('error', () => {}) const parser1 = createParser({ protocolVersion: 5 }) @@ -284,29 +289,31 @@ test('MQTT 5.0 outbound Topic Alias: an aliased QoS 1 message keeps its topic wh } }) raw1.on('data', d => parser1.parse(d)) - raw1.write(connectSub()) + raw1.write(connectSub(true)) await waitFor(() => rx1.some(p => p.cmd === 'suback'), 'subscribed') const pub = connect({ clientId: 'oalias-q1-pub' }) await once(pub, 'connect') - await pub.publishAsync('o/q1', 'one', { qos: 1 }) + const props = { userProperties: { k: 'v' } } // ensures packet.properties exists (reference-shared) + await pub.publishAsync('o/q1', 'one', { qos: 1, properties: props }) await waitFor(() => rx1.filter(p => p.cmd === 'publish').length >= 1, 'first delivery') // ACK the first (full-topic) delivery so only the aliased one is left pending. const first = rx1.find(p => p.cmd === 'publish') raw1.write(generate({ cmd: 'puback', messageId: first.messageId }, { protocolVersion: 5 })) - await pub.publishAsync('o/q1', 'two', { qos: 1 }) + await pub.publishAsync('o/q1', 'two', { qos: 1, properties: props }) await waitFor(() => rx1.filter(p => p.cmd === 'publish').length >= 2, 'aliased delivery') const second = rx1.filter(p => p.cmd === 'publish')[1] t.assert.equal(second.topic, '', 'the second delivery was aliased (empty topic on the wire)') - t.assert.equal(second.payload.toString(), 'two', 'payload delivered') + t.assert.ok(second.properties?.topicAlias >= 1, 'and carried a Topic Alias') // Drop the socket with 'two' un-acked; wait for broker-side teardown. raw1.destroy() await waitFor(() => !broker.clients['oalias-q1'], 'old Client deregistered') - // Reconnect the persistent session: the un-acked 'two' is resent on a fresh - // Client (fresh alias map), so it must carry the full topic — proving the - // stored packet was never mutated to the empty topic it went out as. + // Reconnect the persistent session WITHOUT advertising a Topic Alias Maximum, so + // outbound aliasing is off. The un-acked 'two' is resent as-is: if the earlier + // aliased send had mutated the shared properties, it would carry a stale + // topicAlias here; a proper clone leaves the stored properties untouched. const raw2 = createConnection(port, 'localhost') t.after(() => raw2.destroy()) raw2.on('error', () => {}) @@ -314,10 +321,10 @@ test('MQTT 5.0 outbound Topic Alias: an aliased QoS 1 message keeps its topic wh const rx2 = [] parser2.on('packet', p => rx2.push(p)) raw2.on('data', d => parser2.parse(d)) - raw2.write(connectSub()) + raw2.write(connectSub(false)) await waitFor(() => rx2.some(p => p.cmd === 'publish' && p.payload.toString() === 'two'), 'un-acked message resent') const resent = rx2.find(p => p.cmd === 'publish' && p.payload.toString() === 'two') - t.assert.equal(resent.topic, 'o/q1', 'resent QoS 1 PUBLISH carries the full topic (stored packet not mutated)') + t.assert.strictEqual(resent.properties?.topicAlias, undefined, 'resent PUBLISH carries no stale Topic Alias (stored packet not poisoned)') }) test('MQTT 5.0 outbound Topic Alias: independent per-subscriber sequences on fanout; v3/v4 subscribers get none', async (t) => { @@ -407,6 +414,32 @@ test('MQTT 5.0 outbound Topic Alias: a duplicated Topic Alias Maximum in CONNECT t.assert.equal(delivered.topic, 'o/dup', 'delivered with the full topic (aliasing disabled for the bad max)') }) +test('MQTT 5.0 outbound Topic Alias: a negative Topic Alias Maximum (via preConnect) does not crash delivery', async (t) => { + t.plan(2) + // A preConnect hook can mutate the packet to a negative topicAliasMaximum, which + // passed the old typeof-number guard but not write.js's `!max` — a divergent + // predicate that crashed on first delivery. The `> 0` guard closes the class. + const { connect } = await createServerAndConnect(t, { + brokerOptions: { + preConnect (client, packet, done) { + if (packet.properties) packet.properties.topicAliasMaximum = -1 + done(null, true) + } + } + }) + const sub = connect({ clientId: 'oalias-neg', properties: { topicAliasMaximum: 5 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('o/neg', { qos: 0 }) + const pub = connect({ clientId: 'oalias-neg-pub' }) + await once(pub, 'connect') + await pub.publishAsync('o/neg', 'hi') + await waitFor(() => publishes.length >= 1, 'delivery arrives without crashing') + t.assert.equal(publishes[0].topic, 'o/neg', 'full topic (aliasing disabled for the negative max)') + t.assert.strictEqual(publishes[0].properties?.topicAlias, undefined, 'no alias assigned') +}) + test('MQTT 5.0 subscription identifier is echoed on matching publishes', async (t) => { t.plan(1) const { connect } = await createServerAndConnect(t) From 5051430519952af28b4c5d47791c7409900294c7 Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Mon, 27 Jul 2026 18:37:22 +0200 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20address=20#1117=20round-3=20?= =?UTF-8?q?=E2=80=94=20land=20outbound-alias=20broker=20option=20+=20minor?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major (flagged by three specialists): the outbound Topic Alias cap is now the `outboundTopicAliasMaximum` broker option (default 64, `0` disables) instead of a hardcoded constant — operator-tunable, in the established pattern (defaultOptions + Aedes types + docs bullet). The effective per-connection max is `min(client-advertised, broker option)`. Major: a caller-supplied `properties.topicAlias` on the public `client.publish()` is now treated as caller-owned — forwarded verbatim, not broker-aliased — so the broker table can't disagree with a hand-rolled one and misdeliver. Major (compat CI): `run_compat.py` now exits non-zero on any non-gap, non-skipped failure (and on an unexpected pass), so a regression in a test removed from EXPECTED_GAPS (like test_server_topic_alias) turns the check red instead of only lowering the reported percentage. Minors: - `Number.isInteger` on the advertised max (a fractional preConnect-injected value no longer slips a non-integer through the guard). - Declare `_outboundTopicAliasMaximum`/`_outboundTopicAliases` in the constructor (monomorphic hot-path shape; the `> 0 ⇒ Map` invariant is now local). - `close()` releases both alias maps. - Fanout test reframed to assert distinct per-connection alias numbering + v4 exclusion (the clone invariant is pinned by the QoS 1 poison test; it isn't observable on the QoS 0 fanout path). New tests: client above the broker cap is clamped; `outboundTopicAliasMaximum: 0` disables outbound aliasing. Refs #840 Co-Authored-By: Claude Opus 4.8 --- aedes.js | 11 ++++- docs/Aedes.md | 3 +- lib/client.js | 11 +++++ lib/constants.js | 10 ++-- lib/handlers/connect.js | 19 ++++---- lib/write.js | 7 +++ test/mqtt5.js | 83 +++++++++++++++++++++++++-------- tools/mqtt-compat/run_compat.py | 15 ++++++ types/instance.d.ts | 1 + 9 files changed, 123 insertions(+), 37 deletions(-) diff --git a/aedes.js b/aedes.js index c98f0d04..7863a3d8 100644 --- a/aedes.js +++ b/aedes.js @@ -6,7 +6,7 @@ import memory from 'aedes-persistence' import mqemitter from 'mqemitter' import Client from './lib/client.js' import { $SYS_PREFIX, batch, noop, runSeries, armLongTimer, topicLevelCount } from './lib/utils.js' -import { SESSION_NEVER_EXPIRES, ReasonCodes } from './lib/constants.js' +import { SESSION_NEVER_EXPIRES, OUTBOUND_TOPIC_ALIAS_MAXIMUM_DEFAULT, ReasonCodes } from './lib/constants.js' import pkg from './package.json' with { type: 'json' } const defaultOptions = { @@ -30,6 +30,11 @@ const defaultOptions = { // MQTT 5.0: maximum Topic Alias value the broker accepts from a client. // 0 disables inbound topic aliases (the value advertised in CONNACK). topicAliasMaximum: 0, + // MQTT 5.0: broker-side ceiling on how many Topic Aliases the broker assigns per + // connection on OUTBOUND PUBLISHes (the effective max is min(this, the client's + // advertised Topic Alias Maximum)). Bounds the never-evicting per-connection + // table. 0 disables outbound aliasing entirely. [#840] + outboundTopicAliasMaximum: OUTBOUND_TOPIC_ALIAS_MAXIMUM_DEFAULT, // MQTT 5.0: maximum size (bytes) of a packet the broker accepts. 0 = no // limit (and nothing advertised in CONNACK). maximumPacketSize: 0, @@ -84,6 +89,10 @@ export class Aedes extends EventEmitter { // clamp to a safe [1, 100] range; see MAX_TOPIC_LEVELS this.maxTopicLevels = Math.min(Math.max(opts.maxTopicLevels, 1), MAX_TOPIC_LEVELS) this.topicAliasMaximum = opts.topicAliasMaximum + // Clamp to a non-negative integer; a bad value falls back to the default. + this.outboundTopicAliasMaximum = Number.isInteger(opts.outboundTopicAliasMaximum) && opts.outboundTopicAliasMaximum >= 0 + ? opts.outboundTopicAliasMaximum + : defaultOptions.outboundTopicAliasMaximum this.maximumPacketSize = opts.maximumPacketSize this.receiveMaximum = opts.receiveMaximum this.sessionExpiryIntervalLimit = opts.sessionExpiryIntervalLimit diff --git a/docs/Aedes.md b/docs/Aedes.md index e0d7d823..25403299 100644 --- a/docs/Aedes.md +++ b/docs/Aedes.md @@ -52,7 +52,8 @@ - `id` `` aedes broker unique identifier. __Default__: `uuidv4()` - `connectTimeout` `` maximum waiting time in milliseconds waiting for a [`CONNECT`][CONNECT] packet. __Default__: `30000` - `keepaliveLimit` `` maximum client keep alive time allowed, 0 means no limit. For MQTT 5.0 clients exceeding this, the broker sends a `Server Keep Alive` in the CONNACK and uses it instead of rejecting the connection. __Default__: `0` - - `topicAliasMaximum` `` MQTT 5.0 only. Maximum __inbound__ Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. __Default__: `0`. (Outbound aliasing — the broker aliasing topics on PUBLISHes it sends — is automatic and needs no option; see the callout under [`Event: connackSent`](#event-connacksent).) + - `topicAliasMaximum` `` MQTT 5.0 only. Maximum __inbound__ Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. __Default__: `0`. (See also `outboundTopicAliasMaximum` for the reverse direction, and the callout under [`Event: connackSent`](#event-connacksent).) + - `outboundTopicAliasMaximum` `` MQTT 5.0 only. Broker-side ceiling on how many Topic Aliases the broker assigns per connection on the __outbound__ PUBLISHes it sends to a v5 client. The effective per-connection maximum is `min(this, the client's advertised Topic Alias Maximum)`. Bounds the never-evicting per-connection alias table (keyed by full topic strings), so an untrusted client can't drive it toward 65535 entries. `0` disables outbound aliasing entirely. __Default__: `64` - `maximumPacketSize` `` MQTT 5.0 only. Maximum size in bytes of a packet the broker accepts, advertised in the CONNACK. Enforced: an oversized inbound frame is rejected as soon as its declared length is known — before the rest of its payload is buffered — with a `DISCONNECT` (reason code `0x95`, Packet too large) for connected v5 clients, or a dropped connection with a `connectionError`/`clientError` for pre-auth or v3/v4 clients. `0` means no limit. __Default__: `0` - `receiveMaximum` `` MQTT 5.0 only. Maximum number of in-flight QoS 1/2 PUBLISH packets advertised to the client in the CONNACK. __Advisory only__: the value is advertised but the broker does not currently enforce an inbound in-flight window (existing `drainTimeout` transport backpressure applies instead); enforcement is planned for a follow-up. `0` means it is not advertised (clients assume the protocol default of `65535`). __Default__: `0` - `sessionExpiryIntervalLimit` `` MQTT 5.0 only. Upper bound in seconds on the Session Expiry Interval a client may request. A larger requested value — including `0xFFFFFFFF` ("never expires") — is clamped to this, both in CONNECT and in a DISCONNECT that updates the interval. Bounds how long per-client session-expiry and will-delay timers (and their persisted session state) live, limiting memory accumulation from a single source cycling client identities. `0` means no cap. __Default__: `0` diff --git a/lib/client.js b/lib/client.js index fec8f8bd..48268331 100644 --- a/lib/client.js +++ b/lib/client.js @@ -37,6 +37,12 @@ class Client { // MQTT 5.0 inbound Topic Alias map (alias number -> topic name), scoped // to this connection. Populated as the client registers aliases. this._inboundTopicAliases = new Map() + // MQTT 5.0 outbound Topic Alias state (broker -> client), negotiated at + // CONNECT. Declared here (not lazily in connect.js) so write()'s hot path + // reads a monomorphic shape on every client, and so `_outboundTopicAliasMaximum + // > 0 ⇒ _outboundTopicAliases is a Map` is a local, enforced invariant. + this._outboundTopicAliasMaximum = 0 + this._outboundTopicAliases = null this.broker = broker this.conn = conn @@ -418,6 +424,11 @@ class Client { conn.removeAllListeners('readable') this._parser._queue = null + // Release the per-connection Topic Alias maps promptly (like _parser._queue), + // shortening the retention window if anything still references a closed client + // (conn.client backref, in-flight mqemitter callbacks, a takeover race). [#840] + this._inboundTopicAliases = null + this._outboundTopicAliases = null if (this._keepaliveTimer) { clearTimeout(this._keepaliveTimer) diff --git a/lib/constants.js b/lib/constants.js index 477d5ab8..9e03fc4c 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -12,13 +12,13 @@ export const SUBSCRIPTION_IDENTIFIER_MAX = 268435455 // [MQTT-3.1.2.11.3] Default Receive Maximum when a client omits the property. export const RECEIVE_MAXIMUM_DEFAULT = 65535 -// [#840] Broker-side ceiling on how many outbound Topic Aliases the broker will -// assign per connection, regardless of the (client-advertised) Topic Alias +// [#840] Default broker-side ceiling on how many outbound Topic Aliases the broker +// assigns per connection, regardless of the (client-advertised) Topic Alias // Maximum. The table never evicts and is keyed by full topic strings, so an // unbounded client-chosen size is a per-connection memory-amplification vector; -// 64 covers realistic topic sets. A configurable broker option is tracked in -// #1124. -export const OUTBOUND_TOPIC_ALIAS_LIMIT = 64 +// 64 covers realistic topic sets. Operator-tunable via the `outboundTopicAliasMaximum` +// broker option (0 disables outbound aliasing). +export const OUTBOUND_TOPIC_ALIAS_MAXIMUM_DEFAULT = 64 // MQTT 5.0 [MQTT-2.4]: reason codes >= 0x80 indicate failure; < 0x80 are success // (0x00) / normal outcomes. Used to classify SUBACK grants and inbound PUBREC. diff --git a/lib/handlers/connect.js b/lib/handlers/connect.js index c414c832..b58b15d2 100644 --- a/lib/handlers/connect.js +++ b/lib/handlers/connect.js @@ -4,7 +4,7 @@ import write from '../write.js' import QoSPacket from '../qos-packet.js' import { runSeries, through } from '../utils.js' import handleSubscribe from './subscribe.js' -import { SESSION_NEVER_EXPIRES, RECEIVE_MAXIMUM_DEFAULT, OUTBOUND_TOPIC_ALIAS_LIMIT, ReasonCodes } from '../constants.js' +import { SESSION_NEVER_EXPIRES, RECEIVE_MAXIMUM_DEFAULT, ReasonCodes } from '../constants.js' // Resolves the effective Session Expiry Interval (in seconds) from a CONNECT. // For MQTT 5.0 it comes from the property (default 0 = ends with the network @@ -306,16 +306,15 @@ function init (client, packet, done) { // [#840] MQTT 5.0 outbound Topic Alias: the client's Topic Alias Maximum is the // highest alias value it will accept from the broker (§3.1.2.11.5). When > 0, // the broker MAY alias outbound PUBLISH topics; track the per-connection map. - // Only a positive number is honored. mqtt-packet decodes a duplicated property - // into an array (a Protocol Error per §3.1.2.11.5); a preConnect hook could also - // set a negative value on the mutable packet. Gate on `> 0` — the same predicate - // write.js uses (`!max`) — so a non-positive/array value yields 0 at BOTH sites - // and the guards can't diverge into a bogus-max crash. The effective size is - // clamped to a broker-side ceiling (never-evicting, client-keyed table — see - // OUTBOUND_TOPIC_ALIAS_LIMIT); a configurable cap is tracked in #1124. + // The effective per-connection maximum is min(client-advertised, broker option) + // — the broker's `outboundTopicAliasMaximum` bounds the never-evicting, + // client-keyed table (0 disables outbound aliasing entirely). Require a positive + // INTEGER: mqtt-packet decodes a duplicated property to an array and a preConnect + // hook can set a fractional/negative value on the mutable packet; the `> 0` + // integer gate matches write.js's `!max` so a bad value yields 0 at both sites. const advertisedTopicAliasMax = packet.properties?.topicAliasMaximum - client._outboundTopicAliasMaximum = packet.protocolVersion === 5 && advertisedTopicAliasMax > 0 - ? Math.min(advertisedTopicAliasMax, OUTBOUND_TOPIC_ALIAS_LIMIT) + client._outboundTopicAliasMaximum = packet.protocolVersion === 5 && Number.isInteger(advertisedTopicAliasMax) && advertisedTopicAliasMax > 0 + ? Math.min(advertisedTopicAliasMax, client.broker.outboundTopicAliasMaximum) : 0 if (client._outboundTopicAliasMaximum > 0) { client._outboundTopicAliases = new Map() diff --git a/lib/write.js b/lib/write.js index 292f3253..1069e286 100644 --- a/lib/write.js +++ b/lib/write.js @@ -28,6 +28,13 @@ function withOutboundTopicAlias (client, packet, version) { if (!max || version !== 5 || packet.cmd !== 'publish' || typeof packet.topic !== 'string' || packet.topic === '') { return packet } + // A caller-set Topic Alias (via the public client.publish()) is caller-owned: + // forward it verbatim and don't broker-alias, so the broker's table can't + // disagree with a hand-rolled one and misdeliver. (The empty-topic reuse form + // already passes through the guard above.) + if (packet.properties?.topicAlias !== undefined) { + return packet + } const aliases = client._outboundTopicAliases const known = aliases.get(packet.topic) if (known !== undefined) { diff --git a/test/mqtt5.js b/test/mqtt5.js index b797d576..66260c57 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -252,6 +252,44 @@ test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full t t.assert.strictEqual(publishes[1].properties?.topicAlias, undefined, 'no alias assigned once the table is full') }) +test('MQTT 5.0 outbound Topic Alias: a client advertising above the broker cap is clamped to it', async (t) => { + t.plan(2) + // The broker option bounds the effective max even when the client advertises far + // more: with outboundTopicAliasMaximum: 2 and a client advertising 100, only 2 + // aliases are assigned and the 3rd distinct topic falls back to the full name. + const { connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 2 } }) + const sub = connect({ clientId: 'oalias-cap', properties: { topicAliasMaximum: 100 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('c/#', { qos: 0 }) + const pub = connect({ clientId: 'oalias-cap-pub' }) + await once(pub, 'connect') + await pub.publishAsync('c/a', '1') + await pub.publishAsync('c/b', '2') + await pub.publishAsync('c/c', '3') // 3rd distinct topic — beyond the broker cap of 2 + await waitFor(() => publishes.length >= 3, 'three deliveries') + t.assert.equal(publishes[1].properties?.topicAlias, 2, 'second distinct topic still gets alias 2') + t.assert.strictEqual(publishes[2].properties?.topicAlias, undefined, 'third falls back to the full topic (broker cap 2)') +}) + +test('MQTT 5.0 outbound Topic Alias: outboundTopicAliasMaximum 0 disables outbound aliasing', async (t) => { + t.plan(2) + const { connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 0 } }) + const sub = connect({ clientId: 'oalias-off-opt', properties: { topicAliasMaximum: 5 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('d/x', { qos: 0 }) + const pub = connect({ clientId: 'oalias-off-opt-pub' }) + await once(pub, 'connect') + await pub.publishAsync('d/x', '1') + await pub.publishAsync('d/x', '2') + await waitFor(() => publishes.length >= 2, 'both deliveries') + t.assert.equal(publishes[1].topic, 'd/x', 'full topic kept (broker disabled outbound aliasing)') + t.assert.strictEqual(publishes[1].properties?.topicAlias, undefined, 'no alias assigned') +}) + test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not poison the stored packet', async (t) => { t.plan(3) // write.js SPREADS (shallow-clones) rather than mutating. aedes-packet @@ -327,11 +365,14 @@ test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not t.assert.strictEqual(resent.properties?.topicAlias, undefined, 'resent PUBLISH carries no stale Topic Alias (stored packet not poisoned)') }) -test('MQTT 5.0 outbound Topic Alias: independent per-subscriber sequences on fanout; v3/v4 subscribers get none', async (t) => { - t.plan(8) - // A single publish fans out to per-subscriber Packets that reference-share one - // `properties` object (aedes-packet), so aliasing must spread (clone) rather - // than mutate. Also: a v3/v4 subscriber must never receive a Topic Alias. +test('MQTT 5.0 outbound Topic Alias: fanout assigns distinct per-subscriber aliases; v3/v4 subscribers get none', async (t) => { + t.plan(5) + // Each connection keeps its OWN alias table: subA racks up an alias on a private + // topic first, so on the shared topic subA assigns alias 2 while subB assigns + // alias 1 — distinct, per-connection numbering. A v3/v4 subscriber never receives + // an alias. (The clone-not-mutate invariant is pinned by the QoS 1 poison test + // above; it isn't observable on the QoS 0 fanout path, where each subscriber + // re-runs aliasing synchronously before its own serialize.) const { port, connect } = await createServerAndConnect(t) const collect = (client) => { const pubs = [] @@ -341,14 +382,15 @@ test('MQTT 5.0 outbound Topic Alias: independent per-subscriber sequences on fan const subA = connect({ clientId: 'fan-a', properties: { topicAliasMaximum: 5 } }) const subB = connect({ clientId: 'fan-b', properties: { topicAliasMaximum: 5 } }) - // A v3.1.1 subscriber on the same broker — must never see a Topic Alias. const subV4 = mqtt.connect({ port, host: 'localhost', protocolVersion: 4, clientId: 'fan-v4', reconnectPeriod: 0 }) t.after(() => subV4.end(true)) await Promise.all([once(subA, 'connect'), once(subB, 'connect'), once(subV4, 'connect')]) const pubsA = collect(subA) const pubsB = collect(subB) const pubsV4 = collect(subV4) + // subA also subscribes a private topic to burn alias 1 before the shared one. await Promise.all([ + subA.subscribeAsync('o/priv', { qos: 0 }), subA.subscribeAsync('o/fan', { qos: 0 }), subB.subscribeAsync('o/fan', { qos: 0 }), subV4.subscribeAsync('o/fan', { qos: 0 }) @@ -356,20 +398,21 @@ test('MQTT 5.0 outbound Topic Alias: independent per-subscriber sequences on fan const pub = connect({ clientId: 'fan-pub' }) await once(pub, 'connect') - await pub.publishAsync('o/fan', 'one') - await pub.publishAsync('o/fan', 'two') - await waitFor(() => pubsA.length >= 2 && pubsB.length >= 2 && pubsV4.length >= 2, 'all subscribers received both') - - // Each v5 subscriber gets its OWN alias sequence: full topic + alias, then empty. - t.assert.equal(pubsA[0].topic, 'o/fan', 'sub A: first carries the full topic') - t.assert.ok(pubsA[0].properties?.topicAlias >= 1, 'sub A: first assigns an alias') - t.assert.equal(pubsA[1].topic, '', 'sub A: second reuses the alias (empty topic)') - t.assert.equal(pubsB[0].topic, 'o/fan', 'sub B: first carries the full topic independently') - t.assert.equal(pubsB[1].topic, '', 'sub B: second reuses its own alias') - t.assert.equal(pubsA[1].properties?.topicAlias, pubsB[1].properties?.topicAlias, 'per-connection aliases (both 1) — spread, not shared/mutated') - // The v3/v4 subscriber must get the full topic on BOTH deliveries and no alias. - t.assert.equal(pubsV4[1].topic, 'o/fan', 'v4 sub: full topic on every delivery') - t.assert.strictEqual(pubsV4[1].properties?.topicAlias, undefined, 'v4 sub: never receives a Topic Alias') + await pub.publishAsync('o/priv', 'p') // only subA — takes subA's alias 1 + await waitFor(() => pubsA.length >= 1, 'subA got the private delivery') + await pub.publishAsync('o/fan', 'shared') // both v5 subs + the v4 sub + await waitFor(() => pubsA.length >= 2 && pubsB.length >= 1 && pubsV4.length >= 1, 'shared delivery fanned out') + + const aFan = pubsA.find(p => p.payload.toString() === 'shared') + // subA already used alias 1 for o/priv, so o/fan is alias 2 for subA... + t.assert.equal(aFan.properties?.topicAlias, 2, 'sub A: o/fan is alias 2 (o/priv took alias 1)') + // ...while subB, with a fresh table, assigns alias 1 to o/fan. Distinct values + // prove the shared properties object was cloned, not mutated across subscribers. + t.assert.equal(pubsB[0].properties?.topicAlias, 1, 'sub B: o/fan is alias 1 independently') + t.assert.equal(aFan.topic, 'o/fan', 'both v5 subs carry the full topic on first use') + // The v3/v4 subscriber must get the full topic and no alias. + t.assert.equal(pubsV4[0].topic, 'o/fan', 'v4 sub: full topic') + t.assert.strictEqual(pubsV4[0].properties?.topicAlias, undefined, 'v4 sub: never receives a Topic Alias') }) test('MQTT 5.0 outbound Topic Alias: a duplicated Topic Alias Maximum in CONNECT does not crash delivery', async (t) => { diff --git a/tools/mqtt-compat/run_compat.py b/tools/mqtt-compat/run_compat.py index 2eb6ec1a..f7f8fa39 100644 --- a/tools/mqtt-compat/run_compat.py +++ b/tools/mqtt-compat/run_compat.py @@ -356,6 +356,21 @@ def main(): print("These tests are in EXPECTED_GAPS but now pass; update " "tools/mqtt-compat/run_compat.py.", file=sys.stderr) + # Give the CI job a meaningful exit code. A genuine regression — a non-gap, + # non-skipped test that did not pass — must turn the check red, not merely lower + # the reported percentage (a test removed from EXPECTED_GAPS, like + # test_server_topic_alias in #840, is otherwise unguarded). A stale gap list + # (an unexpected pass) fails too. + regressions = [(r["label"], t["name"], t["status"]) + for r in reports for t in r["results"] + if t["status"] not in ("pass", "skip") and not t.get("expected_gap")] + if regressions: + listed = ", ".join(f"{name} [{status}] ({label})" + for label, name, status in regressions) + print(f"REGRESSION: {listed}", file=sys.stderr) + if regressions or xpass: + sys.exit(1) + if __name__ == "__main__": main() diff --git a/types/instance.d.ts b/types/instance.d.ts index 2c3b45f1..fd407175 100644 --- a/types/instance.d.ts +++ b/types/instance.d.ts @@ -90,6 +90,7 @@ export interface AedesOptions { trustedProxies?: string[]; // MQTT 5.0 broker limits, advertised in CONNACK. topicAliasMaximum?: number; // max inbound topic alias; 0 disables (default: 0) + outboundTopicAliasMaximum?: number; // broker-side cap on outbound topic aliases per connection; 0 disables (default: 64) maximumPacketSize?: number; // max accepted packet size in bytes; 0 = no limit (default: 0) receiveMaximum?: number; // advertised max in-flight QoS 1/2; 0 = not advertised (default: 0) sessionExpiryIntervalLimit?: number; // clamp (seconds) on requested Session Expiry Interval; 0 = no cap (default: 0) From 4f7df3b48c57d3d50a651c68099c9cc7ed381d5a Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Mon, 27 Jul 2026 18:48:20 +0200 Subject: [PATCH 06/12] fix: narrow the compat CI gate to EXPECTED_PASSES (don't fail on pre-existing gaps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous "exit non-zero on any non-gap failure" turned the Paho job red on ~6 pre-existing not-yet-implemented features (test_maximum_packet_size, test_offline_message_queueing, …) that aren't tracked in EXPECTED_GAPS. Narrow the gate to an EXPECTED_PASSES set — tests aedes implements whose Paho case is their only interop evidence (test_server_topic_alias, removed from EXPECTED_GAPS here) — so a regression there turns the check red while the WIP denominator stays counted in the percentage. Unexpected passes still fail. Refs #840 Co-Authored-By: Claude Opus 4.8 --- tools/mqtt-compat/run_compat.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tools/mqtt-compat/run_compat.py b/tools/mqtt-compat/run_compat.py index f7f8fa39..1f662141 100644 --- a/tools/mqtt-compat/run_compat.py +++ b/tools/mqtt-compat/run_compat.py @@ -62,6 +62,17 @@ "v3": {}, } +# Tests that MUST pass — features aedes implements whose Paho coverage is the only +# automated interop evidence (e.g. broker-assigned Topic Alias, removed from +# EXPECTED_GAPS in #840). A regression in one of these turns the CI check red. The +# broad set of not-yet-implemented features (test_maximum_packet_size, +# test_offline_message_queueing, …) stays counted in the percentage only — they are +# a work-in-progress denominator, not a red-check gate; a removed-gap regression is. +EXPECTED_PASSES = { + "v5": {"test_server_topic_alias"}, + "v3": set(), +} + PROTOCOL_LABEL = {"v5": "MQTT 5.0", "v3": "MQTT 3.1.1"} TEST_FILES = {"v5": "client_test5.py", "v3": "client_test.py"} @@ -356,18 +367,20 @@ def main(): print("These tests are in EXPECTED_GAPS but now pass; update " "tools/mqtt-compat/run_compat.py.", file=sys.stderr) - # Give the CI job a meaningful exit code. A genuine regression — a non-gap, - # non-skipped test that did not pass — must turn the check red, not merely lower - # the reported percentage (a test removed from EXPECTED_GAPS, like - # test_server_topic_alias in #840, is otherwise unguarded). A stale gap list - # (an unexpected pass) fails too. + # Give the CI job a meaningful exit code. A regression in an EXPECTED_PASSES + # test — a feature aedes implements, whose Paho case is its only interop + # evidence (e.g. test_server_topic_alias, removed from EXPECTED_GAPS in #840) — + # must turn the check red, not merely lower the percentage. (The broad set of + # not-yet-implemented features stays a work-in-progress denominator.) A stale + # gap list — an unexpected pass — fails too. regressions = [(r["label"], t["name"], t["status"]) for r in reports for t in r["results"] - if t["status"] not in ("pass", "skip") and not t.get("expected_gap")] + if t["name"] in EXPECTED_PASSES.get(r["protocol"], set()) + and t["status"] != "pass"] if regressions: listed = ", ".join(f"{name} [{status}] ({label})" for label, name, status in regressions) - print(f"REGRESSION: {listed}", file=sys.stderr) + print(f"REGRESSION (expected-pass test failed): {listed}", file=sys.stderr) if regressions or xpass: sys.exit(1) From 5e02cf578b12d8936c7df349c1ae26cd60c53fec Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Mon, 27 Jul 2026 19:07:09 +0200 Subject: [PATCH 07/12] test: cover the caller-supplied topicAlias pass-through (codecov/patch) The caller-owned topicAlias early-return in write.js was the only uncovered patch line. Add a test: a broker-side client.publish() with properties.topicAlias set is forwarded verbatim (topic kept, alias 3 not reassigned). Refs #840 Co-Authored-By: Claude Opus 4.8 --- test/mqtt5.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/mqtt5.js b/test/mqtt5.js index 66260c57..a037e7f4 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -233,6 +233,24 @@ test('MQTT 5.0 outbound Topic Alias: not used when the client did not advertise t.assert.strictEqual(publishes[1].properties?.topicAlias, undefined, 'no topic alias assigned') }) +test('MQTT 5.0 outbound Topic Alias: a caller-supplied topicAlias is forwarded verbatim, not broker-aliased', async (t) => { + t.plan(2) + // A packet reaching the public client.publish() with properties.topicAlias set is + // caller-owned: the broker must forward it verbatim (not assign its own alias), + // so the two tables can't disagree and misdeliver. + const { broker, connect } = await createServerAndConnect(t) + const sub = connect({ clientId: 'oalias-caller', properties: { topicAliasMaximum: 5 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + while (!broker.clients['oalias-caller']) await delay(5) + // Directly publish through the broker-side Client with a caller-set topicAlias. + broker.clients['oalias-caller'].publish({ cmd: 'publish', topic: 'x/caller', payload: Buffer.from('hi'), qos: 0, properties: { topicAlias: 3 } }, () => {}) + await waitFor(() => publishes.length >= 1, 'delivery') + t.assert.equal(publishes[0].topic, 'x/caller', 'full topic kept (caller owns the alias)') + t.assert.equal(publishes[0].properties?.topicAlias, 3, 'the caller topicAlias is forwarded verbatim, not reassigned') +}) + test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full topic', async (t) => { t.plan(3) const { connect } = await createServerAndConnect(t) From 3935f52d9dc37f9745d3612cb39aa14fbeff85a6 Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Thu, 27 Aug 2026 00:19:13 +0200 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20address=20#1117=20round-4=20?= =?UTF-8?q?=E2=80=94=203=20blockers=20by=20shrinking=20the=20alias=20surfa?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blockers: - Will-property alias hijack: a client could set will.properties.topicAlias; resolveTopicAlias only strips it off a live inbound PUBLISH, never off a Will, so on disconnect the Will fanned out with the attacker's alias — hijacking an alias slot in OTHER clients' tables (cross-client misdelivery / forced 0x94). Now the write path scrubs any packet-carried topicAlias (Will, retained, caller) before emitting, and the Will is also scrubbed at storage (the inbound-PUBLISH analogue). - Caller-alias desync: forwarding a caller-set topicAlias verbatim (my prior-round fix) let the broker's table diverge from the client's and misdeliver, and emitted aliases a client never negotiated ([MQTT-3.1.2-26/27]). topicAlias is broker-owned on the outbound path: a caller value is now always stripped and the broker applies its own aliasing. - close() map/max inconsistency: nulling the alias maps while `connected` was still true let an in-flight deliver hit aliases.get() on null (TypeError -> spurious clientError + dropped QoS>0 write). Now `_outboundTopicAliasMaximum` is zeroed in the same statement (write.js short-circuits on !max), and the inbound map is no longer nulled at all (no `!max` guard to hide behind; collected with the Client like subscriptions/duplicates). Major (design/backcompat): `outboundTopicAliasMaximum` now defaults to 0 (opt-in), matching the inbound `topicAliasMaximum` sibling — enabling it changes wire behaviour and adds per-connection memory, so it must not turn on silently on upgrade. This also resolves the memory concern (off by default) and the fail-open input handling: a non-integer / negative value now coerces to 0 (disabled), never to an enabled default. Tests: caller alias stripped (enabled + disabled/[MQTT-3.1.2-27]); Will delivered to subscribers gets a broker alias, never the client-supplied one; retained flush-on-subscribe is aliased and registers the alias. Existing outbound tests updated to enable the now-opt-in option. Docs: the connackSent callout no longer claims "automatic, no option"; option default corrected to 0; README documents the EXPECTED_PASSES CI gate; test-d exercises the new option. Refs #840 Co-Authored-By: Claude Opus 4.8 --- aedes.js | 17 +++-- docs/Aedes.md | 4 +- lib/client.js | 17 ++++- lib/constants.js | 8 --- lib/handlers/connect.js | 9 +++ lib/write.js | 38 ++++++++--- test/mqtt5.js | 132 +++++++++++++++++++++++++++++++----- test/types/aedes.test-d.ts | 1 + tools/mqtt-compat/README.md | 17 ++++- 9 files changed, 193 insertions(+), 50 deletions(-) diff --git a/aedes.js b/aedes.js index 7863a3d8..9fa3057d 100644 --- a/aedes.js +++ b/aedes.js @@ -6,7 +6,7 @@ import memory from 'aedes-persistence' import mqemitter from 'mqemitter' import Client from './lib/client.js' import { $SYS_PREFIX, batch, noop, runSeries, armLongTimer, topicLevelCount } from './lib/utils.js' -import { SESSION_NEVER_EXPIRES, OUTBOUND_TOPIC_ALIAS_MAXIMUM_DEFAULT, ReasonCodes } from './lib/constants.js' +import { SESSION_NEVER_EXPIRES, ReasonCodes } from './lib/constants.js' import pkg from './package.json' with { type: 'json' } const defaultOptions = { @@ -33,8 +33,11 @@ const defaultOptions = { // MQTT 5.0: broker-side ceiling on how many Topic Aliases the broker assigns per // connection on OUTBOUND PUBLISHes (the effective max is min(this, the client's // advertised Topic Alias Maximum)). Bounds the never-evicting per-connection - // table. 0 disables outbound aliasing entirely. [#840] - outboundTopicAliasMaximum: OUTBOUND_TOPIC_ALIAS_MAXIMUM_DEFAULT, + // table. Defaults to 0 (disabled, OPT-IN) to match the inbound `topicAliasMaximum` + // sibling: enabling it changes wire behaviour (clients start receiving empty-topic + // PUBLISHes) and adds per-connection memory, so it should not turn on silently on + // upgrade. Set it (e.g. 64) to enable. [#840] + outboundTopicAliasMaximum: 0, // MQTT 5.0: maximum size (bytes) of a packet the broker accepts. 0 = no // limit (and nothing advertised in CONNACK). maximumPacketSize: 0, @@ -89,10 +92,12 @@ export class Aedes extends EventEmitter { // clamp to a safe [1, 100] range; see MAX_TOPIC_LEVELS this.maxTopicLevels = Math.min(Math.max(opts.maxTopicLevels, 1), MAX_TOPIC_LEVELS) this.topicAliasMaximum = opts.topicAliasMaximum - // Clamp to a non-negative integer; a bad value falls back to the default. - this.outboundTopicAliasMaximum = Number.isInteger(opts.outboundTopicAliasMaximum) && opts.outboundTopicAliasMaximum >= 0 + // Coerce a non-integer / negative value to 0 (disabled), not to the default: + // "bad value" must never mean "silently enabled". A string '0' from env/JSON + // config, -1, or 0.5 all disable outbound aliasing rather than turning it on. + this.outboundTopicAliasMaximum = Number.isInteger(opts.outboundTopicAliasMaximum) && opts.outboundTopicAliasMaximum > 0 ? opts.outboundTopicAliasMaximum - : defaultOptions.outboundTopicAliasMaximum + : 0 this.maximumPacketSize = opts.maximumPacketSize this.receiveMaximum = opts.receiveMaximum this.sessionExpiryIntervalLimit = opts.sessionExpiryIntervalLimit diff --git a/docs/Aedes.md b/docs/Aedes.md index 25403299..2adfe269 100644 --- a/docs/Aedes.md +++ b/docs/Aedes.md @@ -53,7 +53,7 @@ - `connectTimeout` `` maximum waiting time in milliseconds waiting for a [`CONNECT`][CONNECT] packet. __Default__: `30000` - `keepaliveLimit` `` maximum client keep alive time allowed, 0 means no limit. For MQTT 5.0 clients exceeding this, the broker sends a `Server Keep Alive` in the CONNACK and uses it instead of rejecting the connection. __Default__: `0` - `topicAliasMaximum` `` MQTT 5.0 only. Maximum __inbound__ Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. __Default__: `0`. (See also `outboundTopicAliasMaximum` for the reverse direction, and the callout under [`Event: connackSent`](#event-connacksent).) - - `outboundTopicAliasMaximum` `` MQTT 5.0 only. Broker-side ceiling on how many Topic Aliases the broker assigns per connection on the __outbound__ PUBLISHes it sends to a v5 client. The effective per-connection maximum is `min(this, the client's advertised Topic Alias Maximum)`. Bounds the never-evicting per-connection alias table (keyed by full topic strings), so an untrusted client can't drive it toward 65535 entries. `0` disables outbound aliasing entirely. __Default__: `64` + - `outboundTopicAliasMaximum` `` MQTT 5.0 only. Broker-side ceiling on how many Topic Aliases the broker assigns per connection on the __outbound__ PUBLISHes it sends to a v5 client. The effective per-connection maximum is `min(this, the client's advertised Topic Alias Maximum)`. Bounds the never-evicting per-connection alias table (keyed by full topic strings), so an untrusted client can't drive it toward 65535 entries. Opt-in (like `topicAliasMaximum`): `0` disables outbound aliasing entirely. Set it (e.g. `64`) to enable — enabling it changes wire behaviour (clients start receiving empty-topic PUBLISHes) and adds per-connection memory. __Default__: `0` - `maximumPacketSize` `` MQTT 5.0 only. Maximum size in bytes of a packet the broker accepts, advertised in the CONNACK. Enforced: an oversized inbound frame is rejected as soon as its declared length is known — before the rest of its payload is buffered — with a `DISCONNECT` (reason code `0x95`, Packet too large) for connected v5 clients, or a dropped connection with a `connectionError`/`clientError` for pre-auth or v3/v4 clients. `0` means no limit. __Default__: `0` - `receiveMaximum` `` MQTT 5.0 only. Maximum number of in-flight QoS 1/2 PUBLISH packets advertised to the client in the CONNACK. __Advisory only__: the value is advertised but the broker does not currently enforce an inbound in-flight window (existing `drainTimeout` transport backpressure applies instead); enforcement is planned for a follow-up. `0` means it is not advertised (clients assume the protocol default of `65535`). __Default__: `0` - `sessionExpiryIntervalLimit` `` MQTT 5.0 only. Upper bound in seconds on the Session Expiry Interval a client may request. A larger requested value — including `0xFFFFFFFF` ("never expires") — is clamped to this, both in CONNECT and in a DISCONNECT that updates the interval. Bounds how long per-client session-expiry and will-delay timers (and their persisted session state) live, limiting memory accumulation from a single source cycling client identities. `0` means no cap. __Default__: `0` @@ -234,7 +234,7 @@ Emitted when server sends an acknowledge to `client`. Please refer to the MQTT s For MQTT 3.1/3.1.1 the packet carries a `returnCode` (`0` = success). For MQTT 5.0 it instead carries a `reasonCode` (`0x00` = success; the v3/v4 return codes map to the equivalent v5 reason codes) and may carry a `properties` object advertising negotiated capabilities (e.g. `topicAliasMaximum`, `maximumPacketSize`, `receiveMaximum`, `serverKeepAlive`, `assignedClientIdentifier`, `sharedSubscriptionAvailable`). -> __MQTT 5.0 outbound Topic Alias:__ automatic, no option. When a v5 client advertises its own `Topic Alias Maximum` in CONNECT, the broker assigns Topic Aliases on the PUBLISHes it sends to that client — full topic + alias on first use of a topic, empty topic + the same alias thereafter — to save bandwidth. Mappings are per Network Connection (reset on reconnect, never carried across, per [MQTT-3.3.2-11]) and the per-connection table never evicts. The number of aliases assigned is `min(client's advertised maximum, 64)`; a configurable broker-side cap is tracked in [#1124](https://github.com/moscajs/aedes/issues/1124). +> __MQTT 5.0 outbound Topic Alias:__ opt-in via the [`outboundTopicAliasMaximum`](#new-aedesoptions) broker option (default `0` = off). When it is set __and__ a v5 client advertises its own `Topic Alias Maximum` in CONNECT, the broker assigns Topic Aliases on the PUBLISHes it sends to that client — full topic + alias on first use of a topic, empty topic + the same alias thereafter — to save bandwidth. Mappings are per Network Connection (reset on reconnect, never carried across, per [MQTT-3.3.2-11]) and the per-connection table never evicts. The number of aliases assigned is `min(client's advertised maximum, outboundTopicAliasMaximum)`. ## Event: closed diff --git a/lib/client.js b/lib/client.js index 48268331..dcad6b02 100644 --- a/lib/client.js +++ b/lib/client.js @@ -424,10 +424,23 @@ class Client { conn.removeAllListeners('readable') this._parser._queue = null - // Release the per-connection Topic Alias maps promptly (like _parser._queue), + // Release the outbound Topic Alias table promptly (like _parser._queue), // shortening the retention window if anything still references a closed client // (conn.client backref, in-flight mqemitter callbacks, a takeover race). [#840] - this._inboundTopicAliases = null + // + // `connected` isn't cleared until finish() runs (after the async + // handleUnsubscribe), so an in-flight deliver0/deliverQoS (both setImmediate) + // can still reach write() in this window. Zero the outbound MAXIMUM in the SAME + // statement so withOutboundTopicAlias short-circuits on `!max` before it touches + // the now-null Map — never leave the max/map pair inconsistent (the constructor + // invariant: `_outboundTopicAliasMaximum > 0` ⇒ `_outboundTopicAliases` is a Map). + // + // The INBOUND map is deliberately NOT nulled: `resolveTopicAlias` has no `!max` + // short-circuit to hide behind, and its deref isn't inside write()'s try/catch, + // so nulling it would risk an uncaught throw from a PUBLISH parsed in the same + // synchronous batch as the close. Like `subscriptions`/`duplicates` (larger maps + // that are also not nulled), it is collected with the Client on disconnect. + this._outboundTopicAliasMaximum = 0 this._outboundTopicAliases = null if (this._keepaliveTimer) { diff --git a/lib/constants.js b/lib/constants.js index 9e03fc4c..329b5bef 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -12,14 +12,6 @@ export const SUBSCRIPTION_IDENTIFIER_MAX = 268435455 // [MQTT-3.1.2.11.3] Default Receive Maximum when a client omits the property. export const RECEIVE_MAXIMUM_DEFAULT = 65535 -// [#840] Default broker-side ceiling on how many outbound Topic Aliases the broker -// assigns per connection, regardless of the (client-advertised) Topic Alias -// Maximum. The table never evicts and is keyed by full topic strings, so an -// unbounded client-chosen size is a per-connection memory-amplification vector; -// 64 covers realistic topic sets. Operator-tunable via the `outboundTopicAliasMaximum` -// broker option (0 disables outbound aliasing). -export const OUTBOUND_TOPIC_ALIAS_MAXIMUM_DEFAULT = 64 - // MQTT 5.0 [MQTT-2.4]: reason codes >= 0x80 indicate failure; < 0x80 are success // (0x00) / normal outcomes. Used to classify SUBACK grants and inbound PUBREC. export const REASON_CODE_ERROR_THRESHOLD = 0x80 diff --git a/lib/handlers/connect.js b/lib/handlers/connect.js index b58b15d2..218d4290 100644 --- a/lib/handlers/connect.js +++ b/lib/handlers/connect.js @@ -329,6 +329,15 @@ function init (client, packet, done) { // false — the broker only returns it on explicit request. client._requestResponseInformation = packet.properties?.requestResponseInformation === true client._will = packet.will + // [#840] A Will carries client-supplied properties, and mqtt-packet round-trips + // Topic Alias (0x23) among them. A Topic Alias is meaningless on a stored Will + // (it names an entry in a since-gone connection's alias table) and, once the Will + // fans out to other subscribers, an attacker-chosen value would hijack an alias + // slot in their tables. The write path scrubs it defensively, but drop it at the + // source too — the inbound-PUBLISH analogue lives at publish.js resolveTopicAlias. + if (client._will?.properties?.topicAlias !== undefined) { + delete client._will.properties.topicAlias + } runSeries( new ClientPacketStatus(client, packet), diff --git a/lib/write.js b/lib/write.js index 1069e286..3257856a 100644 --- a/lib/write.js +++ b/lib/write.js @@ -19,31 +19,47 @@ const WRITE_OPTS = { // Returns the object to serialize — a shallow clone when aliased, so the // caller's packet (which persistence may hold by reference for QoS > 0, and // which must keep its real topic for resend after reconnect) is never mutated. +// Return a shallow clone of `packet` with `properties.topicAlias` removed. Used to +// scrub a caller/Will-supplied alias off the outbound path without mutating the +// (reference-shared) source packet. +function stripTopicAlias (packet) { + const properties = { ...packet.properties } + delete properties.topicAlias + return { ...packet, properties } +} + function withOutboundTopicAlias (client, packet, version) { + // `topicAlias` on an outbound PUBLISH is broker-owned: aedes assigns it from its + // per-connection table. A value already on the packet is NOT trustworthy — it can + // arrive via the public client.publish(), or via an attacker-set Will property + // (mqtt-packet round-trips property 0x23 inside will properties, and the inbound + // resolveTopicAlias only strips it off a live PUBLISH, never off a Will). Forwarding + // it would emit an alias the receiving client never negotiated ([MQTT-3.1.2-26/27]) + // or one that disagrees with the broker's own table (misdelivery / alias hijack + // across clients). So a caller alias is always stripped, and the broker applies its + // own aliasing on top. + const callerAlias = version === 5 && packet.cmd === 'publish' && packet.properties?.topicAlias !== undefined + const max = client._outboundTopicAliasMaximum // Only alias non-empty *string* topics: a Buffer topic (reachable via // client.publish(), which skips topic validation) would key the Map by object // identity, so an equal-but-distinct Buffer never matches — burning a fresh // alias slot per publish until the table is permanently full. if (!max || version !== 5 || packet.cmd !== 'publish' || typeof packet.topic !== 'string' || packet.topic === '') { - return packet - } - // A caller-set Topic Alias (via the public client.publish()) is caller-owned: - // forward it verbatim and don't broker-alias, so the broker's table can't - // disagree with a hand-rolled one and misdeliver. (The empty-topic reuse form - // already passes through the guard above.) - if (packet.properties?.topicAlias !== undefined) { - return packet + // Aliasing off / not applicable: pass through, but scrub a stray caller alias so + // the broker never emits an alias the client didn't ask for. + return callerAlias ? stripTopicAlias(packet) : packet } const aliases = client._outboundTopicAliases const known = aliases.get(packet.topic) if (known !== undefined) { - // Registered topic: send an empty topic + the alias. + // Registered topic: send an empty topic + the alias (overwrites any caller alias). return { ...packet, topic: '', properties: { ...packet.properties, topicAlias: known } } } if (aliases.size >= max) { - // Alias table full — send the full topic name, no alias (never evict). - return packet + // Alias table full — send the full topic name, no alias (never evict). Scrub a + // stray caller alias. + return callerAlias ? stripTopicAlias(packet) : packet } // New topic: assign the next alias (1..max) and send the full topic + alias, // which registers the mapping on the client for subsequent PUBLISHes. diff --git a/test/mqtt5.js b/test/mqtt5.js index a037e7f4..b4597792 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -153,7 +153,7 @@ test('MQTT 5.0 inbound topic alias is resolved and delivered with the real topic test('MQTT 5.0 outbound Topic Alias: the broker aliases repeated topics to a v5 client', async (t) => { t.plan(5) - const { connect } = await createServerAndConnect(t) + const { connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) // The subscriber advertises a Topic Alias Maximum, so the broker may alias. const sub = connect({ clientId: 'oalias-sub', properties: { topicAliasMaximum: 5 } }) await once(sub, 'connect') @@ -184,7 +184,7 @@ test('MQTT 5.0 outbound Topic Alias: mappings reset on reconnect, not carried ac // Client gets a fresh alias map, so the first PUBLISH after reconnect on a // previously-aliased topic must carry the full topic + a newly-assigned alias — // never an empty topic referencing an alias the new connection never sent. - const { broker, connect } = await createServerAndConnect(t) + const { broker, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const opts = { clientId: 'oalias-reset', clean: true, reconnectPeriod: 0, properties: { topicAliasMaximum: 5 } } const sub1 = connect(opts) @@ -218,7 +218,9 @@ test('MQTT 5.0 outbound Topic Alias: mappings reset on reconnect, not carried ac test('MQTT 5.0 outbound Topic Alias: not used when the client did not advertise one', async (t) => { t.plan(2) - const { connect } = await createServerAndConnect(t) + // Broker aliasing is enabled, but the client advertises no Topic Alias Maximum, + // so the effective max is 0 and nothing is aliased ([MQTT-3.1.2-27]). + const { connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const sub = connect({ clientId: 'oalias-off' }) // no topicAliasMaximum ⇒ 0 await once(sub, 'connect') const publishes = [] @@ -233,27 +235,36 @@ test('MQTT 5.0 outbound Topic Alias: not used when the client did not advertise t.assert.strictEqual(publishes[1].properties?.topicAlias, undefined, 'no topic alias assigned') }) -test('MQTT 5.0 outbound Topic Alias: a caller-supplied topicAlias is forwarded verbatim, not broker-aliased', async (t) => { - t.plan(2) - // A packet reaching the public client.publish() with properties.topicAlias set is - // caller-owned: the broker must forward it verbatim (not assign its own alias), - // so the two tables can't disagree and misdeliver. - const { broker, connect } = await createServerAndConnect(t) +test('MQTT 5.0 outbound Topic Alias: a caller-supplied topicAlias is stripped, not forwarded (broker owns the namespace)', async (t) => { + t.plan(3) + // topicAlias on an outbound PUBLISH is broker-owned. A caller value (via the + // internal client.publish()) must be scrubbed and the broker's own alias applied + // — forwarding it verbatim desyncs the broker's table from the client's and + // misdelivers, and can emit an alias the client never negotiated + // ([MQTT-3.1.2-26/27]). + const { broker, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const sub = connect({ clientId: 'oalias-caller', properties: { topicAliasMaximum: 5 } }) await once(sub, 'connect') const publishes = [] sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) while (!broker.clients['oalias-caller']) await delay(5) - // Directly publish through the broker-side Client with a caller-set topicAlias. - broker.clients['oalias-caller'].publish({ cmd: 'publish', topic: 'x/caller', payload: Buffer.from('hi'), qos: 0, properties: { topicAlias: 3 } }, () => {}) - await waitFor(() => publishes.length >= 1, 'delivery') - t.assert.equal(publishes[0].topic, 'x/caller', 'full topic kept (caller owns the alias)') - t.assert.equal(publishes[0].properties?.topicAlias, 3, 'the caller topicAlias is forwarded verbatim, not reassigned') + const target = broker.clients['oalias-caller'] + // First publish carries a bogus caller alias 3 — the broker must ignore it and + // assign its own (1) with the full topic. + target.publish({ cmd: 'publish', topic: 'x/caller', payload: Buffer.from('one'), qos: 0, properties: { topicAlias: 3 } }, () => {}) + await waitFor(() => publishes.length >= 1, 'first delivery') + t.assert.equal(publishes[0].topic, 'x/caller', 'full topic kept on first use') + t.assert.equal(publishes[0].properties?.topicAlias, 1, 'broker-assigned alias 1, not the caller value 3') + // Second publish of the same topic reuses the broker alias (empty topic + 1) — + // proving the broker table, not the caller value, governs. + target.publish({ cmd: 'publish', topic: 'x/caller', payload: Buffer.from('two'), qos: 0, properties: { topicAlias: 3 } }, () => {}) + await waitFor(() => publishes.length >= 2, 'second delivery') + t.assert.equal(publishes[1].properties?.topicAlias, 1, 'reuses broker alias 1 (empty topic), caller value ignored') }) test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full topic', async (t) => { t.plan(3) - const { connect } = await createServerAndConnect(t) + const { connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const sub = connect({ clientId: 'oalias-full', properties: { topicAliasMaximum: 1 } }) await once(sub, 'connect') const publishes = [] @@ -319,7 +330,7 @@ test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not // topicAlias — an in-place mutation would resend a stale alias the reconnected // client never agreed to (a Protocol Error). The publisher sends a User Property // so the shared `properties` object exists. §3.3.2-11 / write.js:19-21. - const { broker, port, connect } = await createServerAndConnect(t) + const { broker, port, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const connectSub = (advertiseAlias) => generate({ cmd: 'connect', protocolVersion: 5, @@ -391,7 +402,7 @@ test('MQTT 5.0 outbound Topic Alias: fanout assigns distinct per-subscriber alia // an alias. (The clone-not-mutate invariant is pinned by the QoS 1 poison test // above; it isn't observable on the QoS 0 fanout path, where each subscriber // re-runs aliasing synchronously before its own serialize.) - const { port, connect } = await createServerAndConnect(t) + const { port, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const collect = (client) => { const pubs = [] client.on('packetreceive', p => { if (p.cmd === 'publish') pubs.push(p) }) @@ -439,7 +450,7 @@ test('MQTT 5.0 outbound Topic Alias: a duplicated Topic Alias Maximum in CONNECT // decodes it to an array. That array must not reach the write path as a bogus // max (it would slip past two type-inconsistent guards and throw on the first // delivery). We disable outbound aliasing for the connection rather than crash. - const { port, connect } = await createServerAndConnect(t) + const { port, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const raw = createConnection(port, 'localhost') t.after(() => raw.destroy()) raw.on('error', () => {}) @@ -482,6 +493,7 @@ test('MQTT 5.0 outbound Topic Alias: a negative Topic Alias Maximum (via preConn // predicate that crashed on first delivery. The `> 0` guard closes the class. const { connect } = await createServerAndConnect(t, { brokerOptions: { + outboundTopicAliasMaximum: 5, preConnect (client, packet, done) { if (packet.properties) packet.properties.topicAliasMaximum = -1 done(null, true) @@ -501,6 +513,90 @@ test('MQTT 5.0 outbound Topic Alias: a negative Topic Alias Maximum (via preConn t.assert.strictEqual(publishes[0].properties?.topicAlias, undefined, 'no alias assigned') }) +test('MQTT 5.0 outbound Topic Alias: a retained message delivered on subscribe is aliased and registers the alias', async (t) => { + t.plan(3) + // Retained flush-on-subscribe builds the PUBLISH outside the live-fanout path, so + // it exercises aliasing on a differently-constructed packet (the class the QoS 1 + // poison test guards). The retained delivery is aliased (full topic + alias) and + // registers the mapping, so a later LIVE publish of the same topic reuses it. + const { connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) + const pub = connect({ clientId: 'oalias-ret-pub' }) + await once(pub, 'connect') + await pub.publishAsync('o/ret', 'retained', { retain: true }) + + const sub = connect({ clientId: 'oalias-ret-sub', properties: { topicAliasMaximum: 5 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('o/ret', { qos: 0 }) + await waitFor(() => publishes.length >= 1, 'retained delivery') + t.assert.equal(publishes[0].topic, 'o/ret', 'retained delivery carries the full topic') + t.assert.ok(publishes[0].properties?.topicAlias >= 1, 'retained delivery assigns an alias') + // A live publish of the same topic now reuses the alias the retained flush + // registered — empty topic + the same alias number. + const alias = publishes[0].properties.topicAlias + await pub.publishAsync('o/ret', 'live') + await waitFor(() => publishes.length >= 2, 'live delivery') + t.assert.deepEqual( + { topic: publishes[1].topic, alias: publishes[1].properties?.topicAlias }, + { topic: '', alias }, + 'live publish reuses the retained delivery\'s alias (empty topic)') +}) + +test('MQTT 5.0 outbound Topic Alias: a Will delivered to subscribers gets a broker alias, never a client-supplied one', async (t) => { + t.plan(3) + // The Will PUBLISH is built outside the live-fanout path. A client can set + // properties.topicAlias on its own Will; that value must never reach other + // subscribers (it would hijack an alias slot in their tables — a cross-client + // misdelivery). The broker strips it and applies its own aliasing. + const { port, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) + const sub = connect({ clientId: 'oalias-will-sub', properties: { topicAliasMaximum: 5 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('o/will', { qos: 0 }) + + // A raw client whose Will carries a bogus topicAlias, then drops ungracefully. + const raw = createConnection(port, 'localhost') + raw.on('error', () => {}) + raw.write(generate({ + cmd: 'connect', + protocolVersion: 5, + clientId: 'oalias-willer', + clean: true, + keepalive: 0, + will: { topic: 'o/will', payload: Buffer.from('the-will'), qos: 0, properties: { topicAlias: 1 } } + }, { protocolVersion: 5 })) + await delay(60) + raw.destroy() // ungraceful close ⇒ Will is published + await waitFor(() => publishes.some(p => p.payload.toString() === 'the-will'), 'will delivered') + + const will = publishes.find(p => p.payload.toString() === 'the-will') + t.assert.equal(will.topic, 'o/will', 'the Will carries its real topic, not an empty-topic hijack') + t.assert.ok(will.properties?.topicAlias >= 1, 'the broker assigned its own alias') + // The bogus client alias was 1; the broker must not blindly emit alias 1 with an + // empty topic (which would resolve to whatever the subscriber mapped to 1). + t.assert.notStrictEqual(will.topic, '', 'never an empty topic (client alias was stripped)') +}) + +test('MQTT 5.0 outbound Topic Alias: a caller alias is stripped even when outbound aliasing is disabled [MQTT-3.1.2-27]', async (t) => { + t.plan(2) + // With outbound aliasing off (broker default), a caller/Will-supplied topicAlias + // must NOT be forwarded — [MQTT-3.1.2-27] forbids sending any alias to a client + // that didn't enable it. The write path scrubs it on the disabled passthrough too. + const { broker, connect } = await createServerAndConnect(t) // outboundTopicAliasMaximum defaults to 0 + const sub = connect({ clientId: 'oalias-27-sub', properties: { topicAliasMaximum: 5 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('o/27', { qos: 0 }) + while (!broker.clients['oalias-27-sub']) await delay(5) + broker.clients['oalias-27-sub'].publish({ cmd: 'publish', topic: 'o/27', payload: Buffer.from('x'), qos: 0, properties: { topicAlias: 7 } }, () => {}) + await waitFor(() => publishes.length >= 1, 'delivery') + t.assert.equal(publishes[0].topic, 'o/27', 'full topic kept') + t.assert.strictEqual(publishes[0].properties?.topicAlias, undefined, 'stray caller alias stripped (aliasing disabled)') +}) + test('MQTT 5.0 subscription identifier is echoed on matching publishes', async (t) => { t.plan(1) const { connect } = await createServerAndConnect(t) diff --git a/test/types/aedes.test-d.ts b/test/types/aedes.test-d.ts index 597ac3de..af3050f5 100644 --- a/test/types/aedes.test-d.ts +++ b/test/types/aedes.test-d.ts @@ -24,6 +24,7 @@ const broker = new Aedes({ trustedProxies: ['127.0.0.1'], decodeProtocol: (client: Client, buffer: Buffer) => buffer, topicAliasMaximum: 10, + outboundTopicAliasMaximum: 64, maximumPacketSize: 1048576, receiveMaximum: 20, sessionExpiryIntervalLimit: 86400, diff --git a/tools/mqtt-compat/README.md b/tools/mqtt-compat/README.md index c28d0431..67e5f1ef 100644 --- a/tools/mqtt-compat/README.md +++ b/tools/mqtt-compat/README.md @@ -47,7 +47,7 @@ case where the broker never sends an expected packet would otherwise hang the whole run, and a hung test would corrupt the class-level shared clients later tests reuse. A timed-out test is recorded as a bounded failure. -The percentage is the raw pass rate (`passed / evaluated`). Two categories are +The percentage is the raw pass rate (`passed / evaluated`). Three categories are handled specially: - **Expected gaps** (`EXPECTED_GAPS` in `run_compat.py`) — features aedes @@ -58,6 +58,14 @@ handled specially: `EXPECTED_GAPS` test starts **passing** (the feature got implemented), the report highlights it with a 🎉 banner and the workflow emits a CI warning, prompting you to remove the now-stale entry from `EXPECTED_GAPS`. +- **Expected passes** (`EXPECTED_PASSES`) — the CI hard gate. Features aedes *does* + implement whose Paho case is their only automated interop evidence (currently + `test_server_topic_alias`, moved here out of `EXPECTED_GAPS` when broker-assigned + Topic Alias landed). If one of these ever regresses to non-`pass`, `run_compat.py` + exits non-zero and the job turns **red** — so a regression can't slip through as + merely a lower percentage. (An unexpected pass also fails the build.) The broad + set of not-yet-implemented features stays a work-in-progress denominator, not a + gate. - **Harness-limited** (`HARNESS_LIMITED`) — tests that cannot be evaluated under per-test isolation against *any* broker (currently only `test_flow_control2`, which depends on the persistent client the suite sets up only in single-process @@ -66,12 +74,15 @@ handled specially: ### Adding or moving a test category -Both lists in `run_compat.py` are keyed `protocol -> exact Paho method name -> -reason string`. Pick the dict by what the failure *means*: +The lists in `run_compat.py` are keyed `protocol -> exact Paho method name` +(`EXPECTED_GAPS`/`HARNESS_LIMITED` map to a reason string; `EXPECTED_PASSES` is a +set). Pick by what the failure *means*: - A genuine aedes gap that should drag the score down → **`EXPECTED_GAPS`**. It still runs and counts as a failure; when aedes later implements it the harness flags the 🎉 xpass so you remove the entry. +- A feature aedes implements that must not regress → **`EXPECTED_PASSES`**. Move a + test here (out of `EXPECTED_GAPS`) once it passes and you want CI to gate on it. - A test the harness can't fairly evaluate against *any* broker → **`HARNESS_LIMITED`**. It is skipped before the gap logic and excluded from the denominator. A method must live in **exactly one** list (`HARNESS_LIMITED` is checked first, so a From 0d2f83d569c2ac23cabc0f7c37bef0b847a349de Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Thu, 27 Aug 2026 11:29:00 +0200 Subject: [PATCH 09/12] fix: enable outbound topic aliases on the compat broker (opt-in default) Making outboundTopicAliasMaximum opt-in (default 0) turned off the feature for the Paho compat broker, so the EXPECTED_PASSES-gated test_server_topic_alias regressed to fail (red CI). Enable it on tools/mqtt-compat/broker.js (65535) alongside the inbound topicAliasMaximum, so the compat run measures real broker ability rather than a disabled feature. Verified locally that the compat broker now aliases outbound PUBLISHes. Refs #840 Co-Authored-By: Claude Opus 4.8 --- tools/mqtt-compat/broker.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/mqtt-compat/broker.js b/tools/mqtt-compat/broker.js index f7ed9f36..9e7b311e 100644 --- a/tools/mqtt-compat/broker.js +++ b/tools/mqtt-compat/broker.js @@ -15,7 +15,11 @@ const port = Number(process.env.MQTT_PORT) || 1883 const broker = await Aedes.createBroker({ // Enable inbound topic aliases, off by default, so test_client_topic_alias // reflects real broker ability rather than a disabled feature. - topicAliasMaximum: 65535 + topicAliasMaximum: 65535, + // Enable broker-assigned OUTBOUND topic aliases, likewise off (opt-in) by + // default, so test_server_topic_alias measures real broker ability rather than a + // disabled feature. This is the EXPECTED_PASSES-gated case in run_compat.py. + outboundTopicAliasMaximum: 65535 // NOTE: deliberately NOT setting keepaliveLimit. It would let the v5 // test_server_keep_alive pass, but it currently makes every MQTT 3.1.1 CONNECT // fail with reason code 6 (aedes tries to apply a Server Keep Alive that 3.1.1 From 4d8d41d81666b22b0cfcac9f14b4fa9e5d12197e Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Mon, 7 Sep 2026 14:33:52 +0200 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20address=20#1117=20round-7=20?= =?UTF-8?q?=E2=80=94=20"ship=20with=20minor=20changes"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Majors: - types/instance.d.ts: the outboundTopicAliasMaximum comment said "(default: 64)" but the option defaults to 0 (opt-in). Corrected to "(default: 0)" — 5 lanes flagged this one-word IDE-hover contradiction. - publish.js resolveTopicAlias: reject a non-integer inbound alias with Number.isInteger. A duplicated Topic Alias property decodes to an array; `[1,2] < 1`/`> max` are both false via NaN, so it slipped the range check and .set() a fresh array key per PUBLISH — an unbounded inbound-map DoS bypassing topicAliasMaximum. The inbound sibling of the outbound guard. Test added (rejected 0x94, map does not grow). - run_compat.py: the EXPECTED_PASSES gate was membership-only, so an expected-pass test VANISHING (Paho rename / load failure) went green. Now also fail when an expected-pass name is absent from the evaluated set, or is simultaneously listed as a gap/harness- limited. Minors: - write.js: an empty-topic packet carrying a caller alias is left untouched (stripping would emit a zero-length topic with no alias — malformed [MQTT-3.3.2-1]); stripTopicAlias uses `= undefined` not `delete` (avoids the dictionary-mode deopt, matching publish.js); the commit-before-write comment now names the real stream.destroy → conn 'error' → _onError path (writeToStream doesn't throw). - aedes.js: clamp outboundTopicAliasMaximum to 65535 (Two Byte Integer), matching the maxTopicLevels precedent. - client.js: .clear() the inbound alias map symmetrically on close (kept a Map — safe). - connect.js: document the duplicated-Topic-Alias-Maximum lenient parse as deliberate (degrade a MAY optimisation rather than refuse an otherwise-valid CONNECT). - Tests: QoS1 poison test asserts the resent topic too; full-table test re-verifies a cached alias still resolves; stale write.js line citation fixed; §3.3.2.3.4 citation. - README: the unexpected-pass path fails the build (was "emits a CI warning"). Refs #840 Co-Authored-By: Claude Opus 4.8 --- aedes.js | 5 +++- docs/Aedes.md | 2 +- lib/client.js | 12 ++++----- lib/handlers/connect.js | 8 ++++++ lib/handlers/publish.js | 7 ++++- lib/write.js | 30 +++++++++++++-------- test/mqtt5.js | 47 ++++++++++++++++++++++++++++++--- tools/mqtt-compat/README.md | 4 +-- tools/mqtt-compat/run_compat.py | 25 ++++++++++++++---- types/instance.d.ts | 2 +- 10 files changed, 109 insertions(+), 33 deletions(-) diff --git a/aedes.js b/aedes.js index 9fa3057d..ae199729 100644 --- a/aedes.js +++ b/aedes.js @@ -95,8 +95,11 @@ export class Aedes extends EventEmitter { // Coerce a non-integer / negative value to 0 (disabled), not to the default: // "bad value" must never mean "silently enabled". A string '0' from env/JSON // config, -1, or 0.5 all disable outbound aliasing rather than turning it on. + // Clamp the top to 65535 — Topic Alias is a Two Byte Integer, so a larger value + // (e.g. via a preConnect hook raising the client's advertised max) would make + // Math.min yield an out-of-range alias mqtt-packet's int16 writer can't encode. this.outboundTopicAliasMaximum = Number.isInteger(opts.outboundTopicAliasMaximum) && opts.outboundTopicAliasMaximum > 0 - ? opts.outboundTopicAliasMaximum + ? Math.min(opts.outboundTopicAliasMaximum, 65535) : 0 this.maximumPacketSize = opts.maximumPacketSize this.receiveMaximum = opts.receiveMaximum diff --git a/docs/Aedes.md b/docs/Aedes.md index 2adfe269..9d8b80ef 100644 --- a/docs/Aedes.md +++ b/docs/Aedes.md @@ -234,7 +234,7 @@ Emitted when server sends an acknowledge to `client`. Please refer to the MQTT s For MQTT 3.1/3.1.1 the packet carries a `returnCode` (`0` = success). For MQTT 5.0 it instead carries a `reasonCode` (`0x00` = success; the v3/v4 return codes map to the equivalent v5 reason codes) and may carry a `properties` object advertising negotiated capabilities (e.g. `topicAliasMaximum`, `maximumPacketSize`, `receiveMaximum`, `serverKeepAlive`, `assignedClientIdentifier`, `sharedSubscriptionAvailable`). -> __MQTT 5.0 outbound Topic Alias:__ opt-in via the [`outboundTopicAliasMaximum`](#new-aedesoptions) broker option (default `0` = off). When it is set __and__ a v5 client advertises its own `Topic Alias Maximum` in CONNECT, the broker assigns Topic Aliases on the PUBLISHes it sends to that client — full topic + alias on first use of a topic, empty topic + the same alias thereafter — to save bandwidth. Mappings are per Network Connection (reset on reconnect, never carried across, per [MQTT-3.3.2-11]) and the per-connection table never evicts. The number of aliases assigned is `min(client's advertised maximum, outboundTopicAliasMaximum)`. +> __MQTT 5.0 outbound Topic Alias:__ opt-in via the [`outboundTopicAliasMaximum`](#new-aedesoptions) broker option (default `0` = off). When it is set __and__ a v5 client advertises its own `Topic Alias Maximum` in CONNECT, the broker assigns Topic Aliases on the PUBLISHes it sends to that client — full topic + alias on first use of a topic, empty topic + the same alias thereafter — to save bandwidth. Mappings are per Network Connection (reset on reconnect, never carried across, per §3.3.2.3.4) and the per-connection table never evicts. The number of aliases assigned is `min(client's advertised maximum, outboundTopicAliasMaximum)`. Full documentation is under the [`outboundTopicAliasMaximum`](#new-aedesoptions) option. ## Event: closed diff --git a/lib/client.js b/lib/client.js index dcad6b02..5fe5ada1 100644 --- a/lib/client.js +++ b/lib/client.js @@ -424,7 +424,7 @@ class Client { conn.removeAllListeners('readable') this._parser._queue = null - // Release the outbound Topic Alias table promptly (like _parser._queue), + // Release the per-connection Topic Alias tables promptly (like _parser._queue), // shortening the retention window if anything still references a closed client // (conn.client backref, in-flight mqemitter callbacks, a takeover race). [#840] // @@ -434,14 +434,12 @@ class Client { // statement so withOutboundTopicAlias short-circuits on `!max` before it touches // the now-null Map — never leave the max/map pair inconsistent (the constructor // invariant: `_outboundTopicAliasMaximum > 0` ⇒ `_outboundTopicAliases` is a Map). - // - // The INBOUND map is deliberately NOT nulled: `resolveTopicAlias` has no `!max` - // short-circuit to hide behind, and its deref isn't inside write()'s try/catch, - // so nulling it would risk an uncaught throw from a PUBLISH parsed in the same - // synchronous batch as the close. Like `subscriptions`/`duplicates` (larger maps - // that are also not nulled), it is collected with the Client on disconnect. + // The inbound map is .clear()'d (kept a Map) rather than nulled: resolveTopicAlias + // has no `!max` short-circuit, and .get() on an empty map safely returns an + // "unknown alias" error, so this can't throw on a PUBLISH parsed in the same batch. this._outboundTopicAliasMaximum = 0 this._outboundTopicAliases = null + this._inboundTopicAliases.clear() if (this._keepaliveTimer) { clearTimeout(this._keepaliveTimer) diff --git a/lib/handlers/connect.js b/lib/handlers/connect.js index 218d4290..7bb81ef3 100644 --- a/lib/handlers/connect.js +++ b/lib/handlers/connect.js @@ -312,6 +312,14 @@ function init (client, packet, done) { // INTEGER: mqtt-packet decodes a duplicated property to an array and a preConnect // hook can set a fractional/negative value on the mutable packet; the `> 0` // integer gate matches write.js's `!max` so a bad value yields 0 at both sites. + // + // A duplicated Topic Alias Maximum (§3.1.2.11.5 Protocol Error) is deliberately + // handled LENIENTLY here — the non-integer array simply disables outbound aliasing + // for the connection rather than refusing the CONNECT with 0x82. Outbound aliasing + // is a broker-side MAY optimisation the client can live without, so degrading it is + // safer than dropping an otherwise-valid connection over a malformed optional + // property; the receiveMaximum/authenticationData Protocol Errors above stay strict + // because those affect correctness, not a bandwidth optimisation. const advertisedTopicAliasMax = packet.properties?.topicAliasMaximum client._outboundTopicAliasMaximum = packet.protocolVersion === 5 && Number.isInteger(advertisedTopicAliasMax) && advertisedTopicAliasMax > 0 ? Math.min(advertisedTopicAliasMax, client.broker.outboundTopicAliasMaximum) diff --git a/lib/handlers/publish.js b/lib/handlers/publish.js index f8b625b7..6ba13351 100644 --- a/lib/handlers/publish.js +++ b/lib/handlers/publish.js @@ -150,7 +150,12 @@ function authorizePublish (packet, done) { function resolveTopicAlias (client, packet) { const alias = packet.properties.topicAlias const max = client.broker.topicAliasMaximum - if (alias < 1 || alias > max) { + // A duplicated Topic Alias property decodes to an array (mqtt-packet), and + // `[1,2] < 1` / `> max` are both false via NaN — so a non-integer would slip the + // range check below and `.set()` a fresh array key per PUBLISH, growing the map + // without bound (bypassing topicAliasMaximum entirely). Require an integer first; + // this is the inbound sibling of the guard connect.js applies to the outbound max. + if (!Number.isInteger(alias) || alias < 1 || alias > max) { return new Error(`topic alias ${alias} is out of range (broker topicAliasMaximum is ${max})`) } diff --git a/lib/write.js b/lib/write.js index 3257856a..34d94218 100644 --- a/lib/write.js +++ b/lib/write.js @@ -23,9 +23,10 @@ const WRITE_OPTS = { // scrub a caller/Will-supplied alias off the outbound path without mutating the // (reference-shared) source packet. function stripTopicAlias (packet) { - const properties = { ...packet.properties } - delete properties.topicAlias - return { ...packet, properties } + // Assign `undefined` rather than `delete`: mqtt-packet tolerates it (resolveTopicAlias + // relies on the same), and `delete` would transition the properties object into + // dictionary mode, deopting property access. + return { ...packet, properties: { ...packet.properties, topicAlias: undefined } } } function withOutboundTopicAlias (client, packet, version) { @@ -37,8 +38,13 @@ function withOutboundTopicAlias (client, packet, version) { // it would emit an alias the receiving client never negotiated ([MQTT-3.1.2-26/27]) // or one that disagrees with the broker's own table (misdelivery / alias hijack // across clients). So a caller alias is always stripped, and the broker applies its - // own aliasing on top. - const callerAlias = version === 5 && packet.cmd === 'publish' && packet.properties?.topicAlias !== undefined + // own aliasing on top. Only for a non-empty string topic: an empty-topic packet + // carrying a caller alias is the embedder's own reuse form (via client.publish()) — + // stripping there would emit a zero-length topic with no alias, a malformed frame + // [MQTT-3.3.2-1], so leave that packet untouched. + const callerAlias = version === 5 && packet.cmd === 'publish' && + typeof packet.topic === 'string' && packet.topic !== '' && + packet.properties?.topicAlias !== undefined const max = client._outboundTopicAliasMaximum // Only alias non-empty *string* topics: a Buffer topic (reachable via @@ -65,12 +71,14 @@ function withOutboundTopicAlias (client, packet, version) { // which registers the mapping on the client for subsequent PUBLISHes. // // The mapping is committed before writeToStream runs (see write() below). Safe - // today: a serialize/flush failure calls _onError, which destroys the socket - // and discards this per-connection map, so no later aliased send references an - // alias the client never saw. It would stop being safe if a write path ever - // *drops* a PUBLISH without tearing the connection down (e.g. the planned - // outbound maximumPacketSize enforcement) — commit only after a successful - // write if that lands. [#840] + // today: on a bad packet mqtt.writeToStream does NOT throw — it calls + // stream.destroy(err) and returns false — and that destroy re-emits via + // conn.on('error') into _onError, which discards this per-connection map, so no + // later aliased send references an alias the client never saw. It would stop being + // safe if a write path ever *drops* a PUBLISH without tearing the connection down + // (e.g. the planned outbound maximumPacketSize enforcement, whose `false` currently + // lands in write()'s backpressure branch) — commit only after a successful write if + // that lands. [#840] const alias = aliases.size + 1 aliases.set(packet.topic, alias) return { ...packet, properties: { ...packet.properties, topicAlias: alias } } diff --git a/test/mqtt5.js b/test/mqtt5.js index b4597792..e707fded 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -262,8 +262,8 @@ test('MQTT 5.0 outbound Topic Alias: a caller-supplied topicAlias is stripped, n t.assert.equal(publishes[1].properties?.topicAlias, 1, 'reuses broker alias 1 (empty topic), caller value ignored') }) -test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full topic', async (t) => { - t.plan(3) +test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full topic, and cached aliases still resolve', async (t) => { + t.plan(4) const { connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const sub = connect({ clientId: 'oalias-full', properties: { topicAliasMaximum: 1 } }) await once(sub, 'connect') @@ -279,6 +279,14 @@ test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full t t.assert.equal(publishes[0].properties?.topicAlias, 1, 'first topic gets alias 1') t.assert.equal(publishes[1].topic, 'o/b', 'second topic sent with its full name (table full)') t.assert.strictEqual(publishes[1].properties?.topicAlias, undefined, 'no alias assigned once the table is full') + // The existing mapping must still resolve once the table is full — re-publish the + // cached topic and confirm it reuses alias 1 (empty topic), not a fresh full topic. + await pub.publishAsync('o/a', '3') + await waitFor(() => publishes.length >= 3, 'third delivery') + t.assert.deepEqual( + { topic: publishes[2].topic, alias: publishes[2].properties?.topicAlias }, + { topic: '', alias: 1 }, + 'a cached topic still reuses its alias after the table filled') }) test('MQTT 5.0 outbound Topic Alias: a client advertising above the broker cap is clamped to it', async (t) => { @@ -320,7 +328,7 @@ test('MQTT 5.0 outbound Topic Alias: outboundTopicAliasMaximum 0 disables outbou }) test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not poison the stored packet', async (t) => { - t.plan(3) + t.plan(4) // write.js SPREADS (shallow-clones) rather than mutating. aedes-packet // reference-shares the `properties` object between the delivered QoSPacket and // the persistence-stored packet, so setting `properties.topicAlias` in place on @@ -329,7 +337,7 @@ test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not // Topic Alias Maximum (aliasing off), and assert the resent PUBLISH carries no // topicAlias — an in-place mutation would resend a stale alias the reconnected // client never agreed to (a Protocol Error). The publisher sends a User Property - // so the shared `properties` object exists. §3.3.2-11 / write.js:19-21. + // so the shared `properties` object exists. §3.3.2.3.4 / see withOutboundTopicAlias. const { broker, port, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const connectSub = (advertiseAlias) => generate({ cmd: 'connect', @@ -392,6 +400,10 @@ test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not await waitFor(() => rx2.some(p => p.cmd === 'publish' && p.payload.toString() === 'two'), 'un-acked message resent') const resent = rx2.find(p => p.cmd === 'publish' && p.payload.toString() === 'two') t.assert.strictEqual(resent.properties?.topicAlias, undefined, 'resent PUBLISH carries no stale Topic Alias (stored packet not poisoned)') + // The topic half of the clone claim: an in-place `packet.topic = ''` mutation would + // resend a zero-length topic here (mqtt-packet doesn't reject it), so assert the + // real topic survived too. + t.assert.equal(resent.topic, 'o/q1', 'resent PUBLISH carries the real topic (not an empty-topic mutation)') }) test('MQTT 5.0 outbound Topic Alias: fanout assigns distinct per-subscriber aliases; v3/v4 subscribers get none', async (t) => { @@ -1439,6 +1451,33 @@ test('MQTT 5.0 publish with an out-of-range topic alias is rejected with DISCONN t.assert.equal(packet.reasonCode, 0x94, '0x94 Topic Alias invalid on the wire') }) +test('MQTT 5.0 publish with a non-integer (duplicated) topic alias is rejected, not stored', async (t) => { + t.plan(2) + // A duplicated Topic Alias property decodes to an array; `[1,2] < 1` / `> max` are + // both false via NaN, so without the Number.isInteger gate it would pass the range + // check and .set() a fresh array key per PUBLISH — an unbounded inbound-map DoS + // that bypasses topicAliasMaximum. The broker must reject it with 0x94. + const { broker, connect } = await createServerAndConnect(t, { + brokerOptions: { topicAliasMaximum: 5 } + }) + const client = connect({ clientId: 'alias-nonint', reconnectPeriod: 0 }) + await once(client, 'connect') + const brokerClient = broker.clients['alias-nonint'] + const sizeBefore = brokerClient._inboundTopicAliases.size + + const clientError = once(broker, 'clientError') + const disc = once(client, 'disconnect') + client.stream.write(generate( + { cmd: 'publish', topic: 'x', payload: 'p', qos: 0, properties: { topicAlias: [1, 2] } }, + { protocolVersion: 5 } + )) + + const [packet] = await disc + await clientError + t.assert.equal(packet.reasonCode, 0x94, 'non-integer topic alias rejected with 0x94') + t.assert.equal(brokerClient._inboundTopicAliases.size, sizeBefore, 'the inbound alias map did not grow') +}) + test('MQTT 5.0 publish with an unknown topic alias is rejected with DISCONNECT 0x94', async (t) => { t.plan(2) const { broker, connect } = await createServerAndConnect(t, { diff --git a/tools/mqtt-compat/README.md b/tools/mqtt-compat/README.md index 67e5f1ef..c747953a 100644 --- a/tools/mqtt-compat/README.md +++ b/tools/mqtt-compat/README.md @@ -56,8 +56,8 @@ handled specially: failures; they are only annotated with the reason so the table is readable. Because v5 is a work in progress, the harness also detects the reverse: when an `EXPECTED_GAPS` test starts **passing** (the feature got implemented), the report - highlights it with a 🎉 banner and the workflow emits a CI warning, prompting you - to remove the now-stale entry from `EXPECTED_GAPS`. + highlights it with a 🎉 banner and the run **fails the build** (`run_compat.py` + exits non-zero), prompting you to remove the now-stale entry from `EXPECTED_GAPS`. - **Expected passes** (`EXPECTED_PASSES`) — the CI hard gate. Features aedes *does* implement whose Paho case is their only automated interop evidence (currently `test_server_topic_alias`, moved here out of `EXPECTED_GAPS` when broker-assigned diff --git a/tools/mqtt-compat/run_compat.py b/tools/mqtt-compat/run_compat.py index 1f662141..0e817945 100644 --- a/tools/mqtt-compat/run_compat.py +++ b/tools/mqtt-compat/run_compat.py @@ -373,14 +373,29 @@ def main(): # must turn the check red, not merely lower the percentage. (The broad set of # not-yet-implemented features stays a work-in-progress denominator.) A stale # gap list — an unexpected pass — fails too. - regressions = [(r["label"], t["name"], t["status"]) - for r in reports for t in r["results"] - if t["name"] in EXPECTED_PASSES.get(r["protocol"], set()) - and t["status"] != "pass"] + # + # Membership isn't enough: an EXPECTED_PASSES test that VANISHES from the results + # (a Paho rename on a PAHO_REF bump, a load failure) would leave `regressions` + # empty and the job green — silently dropping the one hard gate. So also fail + # when an expected-pass name never appeared in the evaluated set, and when a name + # is simultaneously listed as a gap/harness-limited (the README requires exactly + # one list; nothing else enforces it). + regressions = [] + for r in reports: + proto = r["protocol"] + evaluated = {t["name"] for t in r["results"]} + for t in r["results"]: + if t["name"] in EXPECTED_PASSES.get(proto, set()) and t["status"] != "pass": + regressions.append((r["label"], t["name"], t["status"])) + for name in EXPECTED_PASSES.get(proto, set()): + if name not in evaluated: + regressions.append((r["label"], name, "absent")) + if name in EXPECTED_GAPS.get(proto, {}) or name in HARNESS_LIMITED.get(proto, {}): + regressions.append((r["label"], name, "also-in-gaps/harness-limited")) if regressions: listed = ", ".join(f"{name} [{status}] ({label})" for label, name, status in regressions) - print(f"REGRESSION (expected-pass test failed): {listed}", file=sys.stderr) + print(f"REGRESSION (expected-pass gate): {listed}", file=sys.stderr) if regressions or xpass: sys.exit(1) diff --git a/types/instance.d.ts b/types/instance.d.ts index fd407175..f4020438 100644 --- a/types/instance.d.ts +++ b/types/instance.d.ts @@ -90,7 +90,7 @@ export interface AedesOptions { trustedProxies?: string[]; // MQTT 5.0 broker limits, advertised in CONNACK. topicAliasMaximum?: number; // max inbound topic alias; 0 disables (default: 0) - outboundTopicAliasMaximum?: number; // broker-side cap on outbound topic aliases per connection; 0 disables (default: 64) + outboundTopicAliasMaximum?: number; // broker-side cap on outbound topic aliases per connection; opt-in, 0 disables (default: 0) maximumPacketSize?: number; // max accepted packet size in bytes; 0 = no limit (default: 0) receiveMaximum?: number; // advertised max in-flight QoS 1/2; 0 = not advertised (default: 0) sessionExpiryIntervalLimit?: number; // clamp (seconds) on requested Session Expiry Interval; 0 = no cap (default: 0) From 0a6236512cddd0980d76a14945f934a81795167d Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Mon, 7 Sep 2026 17:08:23 +0200 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20address=20#1117=20round-8=20?= =?UTF-8?q?=E2=80=94=20caller-alias=20scrub,=20inbound=20clamp,=20test=20t?= =?UTF-8?q?eeth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - write.js: scrub a caller-supplied topicAlias on EVERY topic shape (Buffer and empty-string topics escaped the old non-empty-string-only gate), so the broker never emits an alias it did not assign [MQTT-3.1.2-27]. - aedes.js: clamp inbound topicAliasMaximum like its outbound sibling (Number.isInteger + Math.min(65535)); a bad value disables it rather than advertising an int16 mqtt-packet can't encode or an Infinity bound. - test(poison): the wire can never reveal a properties-poison after the write.js scrub (aliasing-on reconnect overwrites topicAlias, aliasing-off scrubs it), so read the stored packet straight out of persistence — the decisive clone check — and reconnect advertising a max for the realistic resume. Mutation-verified. - test(takeover): replace the reset-on-reconnect tautology (an independent connection trivially has its own map) with a genuine same-id session takeover. - test: cover the inbound topicAliasMaximum clamp (string/fraction disable it, over-range clamps to 65535). Co-Authored-By: Claude Opus 4.8 --- aedes.js | 9 ++++- lib/write.js | 12 +++--- test/mqtt5.js | 108 ++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 94 insertions(+), 35 deletions(-) diff --git a/aedes.js b/aedes.js index ae199729..926c1aee 100644 --- a/aedes.js +++ b/aedes.js @@ -91,7 +91,14 @@ export class Aedes extends EventEmitter { this.maxClientsIdLength = opts.maxClientsIdLength // clamp to a safe [1, 100] range; see MAX_TOPIC_LEVELS this.maxTopicLevels = Math.min(Math.max(opts.maxTopicLevels, 1), MAX_TOPIC_LEVELS) - this.topicAliasMaximum = opts.topicAliasMaximum + // Clamp like its outbound sibling below: it is advertised as an int16 CONNACK + // property, so a non-integer / out-of-range value (65536, Infinity, a JSON-config + // string) would make mqtt-packet stream.destroy() every v5 CONNACK — a + // self-inflicted v5 outage — and Infinity would also lift the `alias > max` bound + // in resolveTopicAlias. A bad value disables inbound aliasing (0). + this.topicAliasMaximum = Number.isInteger(opts.topicAliasMaximum) && opts.topicAliasMaximum > 0 + ? Math.min(opts.topicAliasMaximum, 65535) + : 0 // Coerce a non-integer / negative value to 0 (disabled), not to the default: // "bad value" must never mean "silently enabled". A string '0' from env/JSON // config, -1, or 0.5 all disable outbound aliasing rather than turning it on. diff --git a/lib/write.js b/lib/write.js index 34d94218..af8928af 100644 --- a/lib/write.js +++ b/lib/write.js @@ -38,12 +38,14 @@ function withOutboundTopicAlias (client, packet, version) { // it would emit an alias the receiving client never negotiated ([MQTT-3.1.2-26/27]) // or one that disagrees with the broker's own table (misdelivery / alias hijack // across clients). So a caller alias is always stripped, and the broker applies its - // own aliasing on top. Only for a non-empty string topic: an empty-topic packet - // carrying a caller alias is the embedder's own reuse form (via client.publish()) — - // stripping there would emit a zero-length topic with no alias, a malformed frame - // [MQTT-3.3.2-1], so leave that packet untouched. + // own aliasing on top. This applies to EVERY caller alias, whatever the topic + // shape — a non-empty string, a Buffer (client.publish() skips topic validation), + // or an empty string. Scrubbing a Buffer/non-empty topic yields a valid full-topic + // packet; scrubbing the degenerate empty-topic form (an embedder calling the + // internal publish with `{ topic: '', topicAlias: n }`) yields an unsendable + // zero-length-topic frame — but that input is malformed either way, and dropping + // the unnegotiated alias [MQTT-3.1.2-27] is strictly better than emitting it. const callerAlias = version === 5 && packet.cmd === 'publish' && - typeof packet.topic === 'string' && packet.topic !== '' && packet.properties?.topicAlias !== undefined const max = client._outboundTopicAliasMaximum diff --git a/test/mqtt5.js b/test/mqtt5.js index e707fded..b277f505 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -123,6 +123,28 @@ test('MQTT 5.0 CONNACK advertises topicAliasMaximum when enabled', async (t) => t.assert.equal(connack.properties?.topicAliasMaximum, 10) }) +test('MQTT 5.0 inbound topicAliasMaximum: a non-integer / out-of-range value disables it, never enables a broken max', async (t) => { + t.plan(3) + // topicAliasMaximum is advertised as a Two Byte Integer in CONNACK, so a bad value + // (a JSON-config string, a fraction, or one above 65535) must be coerced to 0 + // (disabled) — never advertised as-is, which would make mqtt-packet destroy every + // v5 CONNACK, and never left as Infinity, which would lift resolveTopicAlias's + // `alias > max` bound. A too-large value clamps to the int16 ceiling instead. + const bad = async (value, label) => { + const { connect } = await createServerAndConnect(t, { brokerOptions: { topicAliasMaximum: value } }) + const client = connect() + const [connack] = await once(client, 'connect') + t.assert.strictEqual(connack.properties?.topicAliasMaximum, undefined, label) + } + await bad('10', 'a string is not advertised') + await bad(0.5, 'a fraction is not advertised') + + const { connect } = await createServerAndConnect(t, { brokerOptions: { topicAliasMaximum: 70000 } }) + const client = connect() + const [connack] = await once(client, 'connect') + t.assert.equal(connack.properties?.topicAliasMaximum, 65535, 'an over-range value clamps to the int16 ceiling') +}) + test('MQTT 5.0 inbound topic alias is resolved and delivered with the real topic', async (t) => { t.plan(4) const { connect } = await createServerAndConnect(t, { @@ -178,14 +200,21 @@ test('MQTT 5.0 outbound Topic Alias: the broker aliases repeated topics to a v5 t.assert.equal(publishes[1].payload.toString(), 'two', 'payload still delivered') }) -test('MQTT 5.0 outbound Topic Alias: mappings reset on reconnect, not carried across connections', async (t) => { +test('MQTT 5.0 outbound Topic Alias: a session takeover starts a fresh alias map, not the taken-over connection\'s', async (t) => { t.plan(3) - // §3.3.2-11: Topic Alias mappings must not survive a Network Connection. A fresh - // Client gets a fresh alias map, so the first PUBLISH after reconnect on a - // previously-aliased topic must carry the full topic + a newly-assigned alias — - // never an empty topic referencing an alias the new connection never sent. + // §3.3.2-11: Topic Alias mappings must not survive a Network Connection — not even + // a session TAKEOVER, where the new connection RESUMES the old session's state. The + // alias map lives on the Client (per-connection), never on the resumed session, so + // the first PUBLISH to a previously-aliased topic on the taking-over connection must + // carry the full topic + a freshly-assigned alias — never an empty topic referencing + // the alias the taken-over connection established but this one never negotiated. + // + // Forcing a real takeover (same clientId, clean:false, overlapping) is what gives + // this test teeth: a fresh independent connection trivially has its own map, so the + // assertion could never fail; only the resume path could leak state if aliases were + // ever (wrongly) stored on the session instead of the Client. const { broker, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) - const opts = { clientId: 'oalias-reset', clean: true, reconnectPeriod: 0, properties: { topicAliasMaximum: 5 } } + const opts = { clientId: 'oalias-reset', clean: false, reconnectPeriod: 0, properties: { topicAliasMaximum: 5, sessionExpiryInterval: 60 } } const sub1 = connect(opts) await once(sub1, 'connect') @@ -200,19 +229,21 @@ test('MQTT 5.0 outbound Topic Alias: mappings reset on reconnect, not carried ac await waitFor(() => pubs1.length >= 2, 'first-connection deliveries') t.assert.equal(pubs1[1].topic, '', 'alias is established on the first connection (empty topic on reuse)') - // Reconnect: a brand-new Client on the broker side, hence a new alias map. - // Wait for broker-side deregistration (not just the client 'close') so sub2 - // does not race the old Client through the takeover path. - sub1.end(true) - await waitFor(() => !broker.clients['oalias-reset'], 'old Client deregistered') + // Takeover: a second connection with the SAME clientId while the first is still + // registered. The broker closes the old Client [MQTT-3.1.4-3] and installs the new + // one, which resumes the session — but MUST get its own empty alias map. + const firstClient = broker.clients['oalias-reset'] const sub2 = connect(opts) await once(sub2, 'connect') + await waitFor(() => broker.clients['oalias-reset'] && broker.clients['oalias-reset'] !== firstClient, 'new Client took over the session') const pubs2 = [] sub2.on('packetreceive', p => { if (p.cmd === 'publish') pubs2.push(p) }) + // Re-subscribe on the taking-over connection (QoS 0, so nothing was queued) and + // publish the previously-aliased topic: the fresh map re-aliases it from scratch. await sub2.subscribeAsync('o/reset', { qos: 0 }) await pub.publishAsync('o/reset', 'three') - await waitFor(() => pubs2.length >= 1, 'delivery after reconnect') - t.assert.equal(pubs2[0].topic, 'o/reset', 'first PUBLISH after reconnect carries the full topic (map reset)') + await waitFor(() => pubs2.length >= 1, 'delivery after takeover') + t.assert.equal(pubs2[0].topic, 'o/reset', 'first PUBLISH after takeover carries the full topic (fresh map)') t.assert.ok(pubs2[0].properties?.topicAlias >= 1, 'and a freshly-assigned alias') }) @@ -328,16 +359,23 @@ test('MQTT 5.0 outbound Topic Alias: outboundTopicAliasMaximum 0 disables outbou }) test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not poison the stored packet', async (t) => { - t.plan(4) + t.plan(6) // write.js SPREADS (shallow-clones) rather than mutating. aedes-packet // reference-shares the `properties` object between the delivered QoSPacket and - // the persistence-stored packet, so setting `properties.topicAlias` in place on - // an aliased send would poison the stored packet. Pin it end-to-end: establish - // an alias, leave the aliased QoS 1 message un-acked, reconnect advertising NO - // Topic Alias Maximum (aliasing off), and assert the resent PUBLISH carries no - // topicAlias — an in-place mutation would resend a stale alias the reconnected - // client never agreed to (a Protocol Error). The publisher sends a User Property - // so the shared `properties` object exists. §3.3.2.3.4 / see withOutboundTopicAlias. + // the persistence-stored packet (Packet copies `topic` by value but aliases + // `properties`), so setting `properties.topicAlias` in place on an aliased send + // would poison the stored packet's properties. That poison is invisible on the + // wire on any resend — an aliasing-ON reconnect OVERWRITES topicAlias with a + // freshly-assigned value, and an aliasing-OFF reconnect SCRUBS it (see + // withOutboundTopicAlias) — so the decisive check reads the STORED packet + // straight out of persistence and asserts its properties were never mutated. + // + // We then also reconnect advertising a Topic Alias Maximum (aliasing back ON) to + // pin the realistic resume end-to-end: a clean stored packet is the fresh map's + // first use of 'o/q1', so it resends full topic + a freshly-assigned alias 1, + // proving the fresh connection re-aliases from scratch rather than replaying a + // stale empty-topic form. The publisher sends a User Property so the shared + // `properties` object exists. §3.3.2.3.4 / see withOutboundTopicAlias. const { broker, port, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) const connectSub = (advertiseAlias) => generate({ cmd: 'connect', @@ -385,10 +423,21 @@ test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not raw1.destroy() await waitFor(() => !broker.clients['oalias-q1'], 'old Client deregistered') - // Reconnect the persistent session WITHOUT advertising a Topic Alias Maximum, so - // outbound aliasing is off. The un-acked 'two' is resent as-is: if the earlier - // aliased send had mutated the shared properties, it would carry a stale - // topicAlias here; a proper clone leaves the stored properties untouched. + // Decisive clone check: read 'two' straight out of persistence (its properties + // object is the very one the aliased delivery handled) and assert the aliased send + // left it untouched — full topic, no topicAlias. An in-place mutation would show + // properties.topicAlias === 1 here, where no resend path can mask it. + const stored = [] + for await (const p of broker.persistence.outgoingStream({ id: 'oalias-q1' })) stored.push(p) + const storedTwo = stored.find(p => p.payload?.toString() === 'two') + t.assert.equal(storedTwo?.topic, 'o/q1', 'stored packet keeps its real topic (delivery did not mutate it)') + t.assert.strictEqual(storedTwo?.properties?.topicAlias, undefined, 'stored packet carries no Topic Alias (properties not poisoned)') + + // Reconnect the persistent session, advertising a Topic Alias Maximum so outbound + // aliasing is back ON with a fresh, empty per-connection map. The un-acked 'two' + // is resent from storage: a clean stored packet is the fresh map's first use of + // 'o/q1', so it resends the full topic + a freshly-assigned alias 1; a poisoned + // stored packet is an empty-topic form whose stale alias gets scrubbed. const raw2 = createConnection(port, 'localhost') t.after(() => raw2.destroy()) raw2.on('error', () => {}) @@ -396,14 +445,15 @@ test('MQTT 5.0 outbound Topic Alias: aliasing an outbound QoS 1 PUBLISH does not const rx2 = [] parser2.on('packet', p => rx2.push(p)) raw2.on('data', d => parser2.parse(d)) - raw2.write(connectSub(false)) + raw2.write(connectSub(true)) await waitFor(() => rx2.some(p => p.cmd === 'publish' && p.payload.toString() === 'two'), 'un-acked message resent') const resent = rx2.find(p => p.cmd === 'publish' && p.payload.toString() === 'two') - t.assert.strictEqual(resent.properties?.topicAlias, undefined, 'resent PUBLISH carries no stale Topic Alias (stored packet not poisoned)') // The topic half of the clone claim: an in-place `packet.topic = ''` mutation would - // resend a zero-length topic here (mqtt-packet doesn't reject it), so assert the - // real topic survived too. + // leave the stored packet as an empty-topic form, resent here as a zero-length topic + // (scrubbed of its stale alias by the fresh map's passthrough). A proper clone keeps + // the real topic, which the fresh connection re-aliases from scratch. t.assert.equal(resent.topic, 'o/q1', 'resent PUBLISH carries the real topic (not an empty-topic mutation)') + t.assert.equal(resent.properties?.topicAlias, 1, 'resent PUBLISH gets a fresh alias from the new connection, not the poisoned connection-1 alias') }) test('MQTT 5.0 outbound Topic Alias: fanout assigns distinct per-subscriber aliases; v3/v4 subscribers get none', async (t) => { From 2e487aedee56845526b2340557ba1082874f4da3 Mon Sep 17 00:00:00 2001 From: Benjamin Dobler Date: Tue, 8 Sep 2026 10:12:51 +0200 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20address=20#1117=20round-8=20(batch?= =?UTF-8?q?=202)=20=E2=80=94=20observability,=20DRY,=20docs,=20compat=20ga?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the round-8 review beyond the top-3 (already in 0a62365): - [Major] write.js:67 — alias-table exhaustion was silent. Emit a one-shot `outboundTopicAliasExhausted` (client, { max }) event the first time a new topic can't be aliased, matching the sessionLimitReached / rejectPacketTooLarge telemetry convention. Latched per connection; typed + tsd + docs + test. - [Major] connect.js:327 / client.js — `_outboundTopicAliases` was the only per-connection Map allocated outside a constructor. Allocate it once in the Client ctor (like _inboundTopicAliases) and `.clear()` it at teardown, dropping the hand-maintained "max > 0 ⇒ Map" invariant; write()'s hot path now always reads a Map. `_outboundTopicAliasMaximum` stays the on/off switch. - [Major] Aedes.md:237 — documented that the caller-alias scrub (and Will scrub) run regardless of outboundTopicAliasMaximum; the option gates alias ASSIGNMENT, not the broker-owns-the-namespace scrub. - [Minor] types/instance.d.ts + Aedes.md — documented the coerce-to-0 / clamp-to-65535 behaviour for both alias-maximum options. - [Minor] run_compat.py — the expected-pass gate skipped a protocol that never produced a report (a --protocols subset / crash); now fails on a wholly-absent gated protocol, and emits a ::error annotation for regressions like the unexpected-pass step does. - [Nit] connect.js:344 — Will topicAlias scrub uses `= undefined`, not `delete`, matching the two sibling scrubs (avoids the dictionary-mode deopt). Co-Authored-By: Claude Opus 4.8 --- docs/Aedes.md | 16 +++++++++++++--- lib/client.js | 29 +++++++++++++++++------------ lib/handlers/connect.js | 13 +++++++++---- lib/write.js | 13 +++++++++++-- test/mqtt5.js | 31 +++++++++++++++++++++++++++++++ test/types/aedes.test-d.ts | 6 ++++++ tools/mqtt-compat/run_compat.py | 15 +++++++++++++++ types/instance.d.ts | 9 +++++++-- 8 files changed, 109 insertions(+), 23 deletions(-) diff --git a/docs/Aedes.md b/docs/Aedes.md index 9d8b80ef..7a4b9ff1 100644 --- a/docs/Aedes.md +++ b/docs/Aedes.md @@ -24,6 +24,7 @@ - [Event: willDropped](#event-willdropped) - [Event: sessionExpired](#event-sessionexpired) - [Event: sessionLimitReached](#event-sessionlimitreached) + - [Event: outboundTopicAliasExhausted](#event-outboundtopicaliasexhausted) - [aedes.handle (stream)](#aedeshandle-stream) - [aedes.subscribe (topic, deliverfunc, callback)](#aedessubscribe-topic-deliverfunc-callback) - [aedes.unsubscribe (topic, deliverfunc, callback)](#aedesunsubscribe-topic-deliverfunc-callback) @@ -52,8 +53,8 @@ - `id` `` aedes broker unique identifier. __Default__: `uuidv4()` - `connectTimeout` `` maximum waiting time in milliseconds waiting for a [`CONNECT`][CONNECT] packet. __Default__: `30000` - `keepaliveLimit` `` maximum client keep alive time allowed, 0 means no limit. For MQTT 5.0 clients exceeding this, the broker sends a `Server Keep Alive` in the CONNACK and uses it instead of rejecting the connection. __Default__: `0` - - `topicAliasMaximum` `` MQTT 5.0 only. Maximum __inbound__ Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. __Default__: `0`. (See also `outboundTopicAliasMaximum` for the reverse direction, and the callout under [`Event: connackSent`](#event-connacksent).) - - `outboundTopicAliasMaximum` `` MQTT 5.0 only. Broker-side ceiling on how many Topic Aliases the broker assigns per connection on the __outbound__ PUBLISHes it sends to a v5 client. The effective per-connection maximum is `min(this, the client's advertised Topic Alias Maximum)`. Bounds the never-evicting per-connection alias table (keyed by full topic strings), so an untrusted client can't drive it toward 65535 entries. Opt-in (like `topicAliasMaximum`): `0` disables outbound aliasing entirely. Set it (e.g. `64`) to enable — enabling it changes wire behaviour (clients start receiving empty-topic PUBLISHes) and adds per-connection memory. __Default__: `0` + - `topicAliasMaximum` `` MQTT 5.0 only. Maximum __inbound__ Topic Alias value the broker accepts from a client, advertised in the CONNACK. `0` disables inbound topic aliases. A non-integer or negative value coerces to `0` (disabled) and a value above `65535` clamps to `65535` (the Two Byte Integer ceiling), so a bad value never silently enables a broken maximum. __Default__: `0`. (See also `outboundTopicAliasMaximum` for the reverse direction, and the callout under [`Event: connackSent`](#event-connacksent).) + - `outboundTopicAliasMaximum` `` MQTT 5.0 only. Broker-side ceiling on how many Topic Aliases the broker assigns per connection on the __outbound__ PUBLISHes it sends to a v5 client. The effective per-connection maximum is `min(this, the client's advertised Topic Alias Maximum)`. Bounds the never-evicting per-connection alias table (keyed by full topic strings), so an untrusted client can't drive it toward 65535 entries. Opt-in (like `topicAliasMaximum`): `0` disables the broker's alias __assignment__ entirely. Set it (e.g. `64`) to enable — enabling it changes wire behaviour (clients start receiving empty-topic PUBLISHes) and adds per-connection memory. A non-integer or negative value (e.g. the string `'64'` arriving from env/JSON config) coerces to `0` (disabled — a bad value must never mean "silently enabled") and a value above `65535` clamps to `65535`. __Default__: `0`. Note that whatever this is set to, a caller-supplied `topicAlias` on an outbound PUBLISH (via `client.publish()`) and on a Will is always stripped — the broker owns the outbound alias namespace (see the callout under [`Event: connackSent`](#event-connacksent)). - `maximumPacketSize` `` MQTT 5.0 only. Maximum size in bytes of a packet the broker accepts, advertised in the CONNACK. Enforced: an oversized inbound frame is rejected as soon as its declared length is known — before the rest of its payload is buffered — with a `DISCONNECT` (reason code `0x95`, Packet too large) for connected v5 clients, or a dropped connection with a `connectionError`/`clientError` for pre-auth or v3/v4 clients. `0` means no limit. __Default__: `0` - `receiveMaximum` `` MQTT 5.0 only. Maximum number of in-flight QoS 1/2 PUBLISH packets advertised to the client in the CONNACK. __Advisory only__: the value is advertised but the broker does not currently enforce an inbound in-flight window (existing `drainTimeout` transport backpressure applies instead); enforcement is planned for a follow-up. `0` means it is not advertised (clients assume the protocol default of `65535`). __Default__: `0` - `sessionExpiryIntervalLimit` `` MQTT 5.0 only. Upper bound in seconds on the Session Expiry Interval a client may request. A larger requested value — including `0xFFFFFFFF` ("never expires") — is clamped to this, both in CONNECT and in a DISCONNECT that updates the interval. Bounds how long per-client session-expiry and will-delay timers (and their persisted session state) live, limiting memory accumulation from a single source cycling client identities. `0` means no cap. __Default__: `0` @@ -234,7 +235,9 @@ Emitted when server sends an acknowledge to `client`. Please refer to the MQTT s For MQTT 3.1/3.1.1 the packet carries a `returnCode` (`0` = success). For MQTT 5.0 it instead carries a `reasonCode` (`0x00` = success; the v3/v4 return codes map to the equivalent v5 reason codes) and may carry a `properties` object advertising negotiated capabilities (e.g. `topicAliasMaximum`, `maximumPacketSize`, `receiveMaximum`, `serverKeepAlive`, `assignedClientIdentifier`, `sharedSubscriptionAvailable`). -> __MQTT 5.0 outbound Topic Alias:__ opt-in via the [`outboundTopicAliasMaximum`](#new-aedesoptions) broker option (default `0` = off). When it is set __and__ a v5 client advertises its own `Topic Alias Maximum` in CONNECT, the broker assigns Topic Aliases on the PUBLISHes it sends to that client — full topic + alias on first use of a topic, empty topic + the same alias thereafter — to save bandwidth. Mappings are per Network Connection (reset on reconnect, never carried across, per §3.3.2.3.4) and the per-connection table never evicts. The number of aliases assigned is `min(client's advertised maximum, outboundTopicAliasMaximum)`. Full documentation is under the [`outboundTopicAliasMaximum`](#new-aedesoptions) option. +> __MQTT 5.0 outbound Topic Alias:__ the broker's alias __assignment__ is opt-in via the [`outboundTopicAliasMaximum`](#new-aedesoptions) broker option (default `0` = off). When it is set __and__ a v5 client advertises its own `Topic Alias Maximum` in CONNECT, the broker assigns Topic Aliases on the PUBLISHes it sends to that client — full topic + alias on first use of a topic, empty topic + the same alias thereafter — to save bandwidth. Mappings are per Network Connection (reset on reconnect, never carried across, per §3.3.2.3.4) and the per-connection table never evicts. The number of aliases assigned is `min(client's advertised maximum, outboundTopicAliasMaximum)`. Full documentation is under the [`outboundTopicAliasMaximum`](#new-aedesoptions) option. +> +> One thing is __not__ gated by the option: a caller-supplied `topicAlias` — set on an outbound PUBLISH passed to `client.publish()`, or carried on a client's Will — is always stripped before the packet reaches another client, even when `outboundTopicAliasMaximum` is `0`. The outbound alias namespace is broker-owned; forwarding a caller value verbatim would emit an alias the receiving client never negotiated ([MQTT-3.1.2-27]) or one that disagrees with the broker's own table. So enabling the option adds broker-assigned aliasing; it does not turn on (or off) that scrub. ## Event: closed @@ -260,6 +263,13 @@ MQTT 5.0 only. Emitted when a session that outlived its connection reaches its S MQTT 5.0 only. Emitted when the [`pendingSessionsLimit`](#new-aedesoptions) DoS guard trips: a disconnecting session that would otherwise be retained (including a never-expiring one) is instead expired immediately (`reason: 'sessionExpiry'`), or a delayed Will is published immediately instead of being held (`reason: 'willDelay'`). Listen for this to detect identity-cycling abuse — without it the guard would degrade behavior silently. +## Event: outboundTopicAliasExhausted + +- `client` [``](./Client.md) +- `info` `` `{ max: number }` — the effective per-connection alias maximum (`min(client's advertised Topic Alias Maximum, outboundTopicAliasMaximum)`) that has been filled + +MQTT 5.0 only. Emitted once per connection, the first time the broker sends a PUBLISH for a __new__ topic that can no longer be aliased because that connection's [`outboundTopicAliasMaximum`](#new-aedesoptions) table is full. The broker keeps working — it falls back to sending the full topic name (the table never evicts) — but throughput for further distinct topics loses the alias saving. Listen for this to detect a `outboundTopicAliasMaximum` set too low for a client's topic cardinality; without it the degradation is silent. + ## aedes.handle (stream) - stream: `` | `` diff --git a/lib/client.js b/lib/client.js index 5fe5ada1..24cef5b5 100644 --- a/lib/client.js +++ b/lib/client.js @@ -38,11 +38,18 @@ class Client { // to this connection. Populated as the client registers aliases. this._inboundTopicAliases = new Map() // MQTT 5.0 outbound Topic Alias state (broker -> client), negotiated at - // CONNECT. Declared here (not lazily in connect.js) so write()'s hot path - // reads a monomorphic shape on every client, and so `_outboundTopicAliasMaximum - // > 0 ⇒ _outboundTopicAliases is a Map` is a local, enforced invariant. + // CONNECT. Allocated once here — like its inbound sibling `_inboundTopicAliases` + // above — rather than lazily in connect.js: write()'s hot path then always reads + // a Map (never null↔Map polymorphism), and teardown `.clear()`s it instead of + // hand-maintaining a "max > 0 ⇒ Map" invariant. `_outboundTopicAliasMaximum` + // stays the on/off switch: 0 (the default) means write() short-circuits before + // it ever touches the map, so an empty map on a non-aliasing client is inert. this._outboundTopicAliasMaximum = 0 - this._outboundTopicAliases = null + this._outboundTopicAliases = new Map() + // One-shot latch for the `outboundTopicAliasExhausted` telemetry event: set the + // first time a new topic can't be aliased because the per-connection table is + // full, so the event fires once per connection rather than per PUBLISH. + this._outboundTopicAliasExhausted = false this.broker = broker this.conn = conn @@ -430,15 +437,13 @@ class Client { // // `connected` isn't cleared until finish() runs (after the async // handleUnsubscribe), so an in-flight deliver0/deliverQoS (both setImmediate) - // can still reach write() in this window. Zero the outbound MAXIMUM in the SAME - // statement so withOutboundTopicAlias short-circuits on `!max` before it touches - // the now-null Map — never leave the max/map pair inconsistent (the constructor - // invariant: `_outboundTopicAliasMaximum > 0` ⇒ `_outboundTopicAliases` is a Map). - // The inbound map is .clear()'d (kept a Map) rather than nulled: resolveTopicAlias - // has no `!max` short-circuit, and .get() on an empty map safely returns an - // "unknown alias" error, so this can't throw on a PUBLISH parsed in the same batch. + // can still reach write() in this window. Zero the outbound MAXIMUM first so + // withOutboundTopicAlias short-circuits on `!max` before it touches the table, + // then `.clear()` both maps (kept as Maps, never nulled — a .get() on an empty + // map safely returns undefined, so an in-flight write can't throw on a stale + // reference). Mirrors the inbound map's teardown exactly. this._outboundTopicAliasMaximum = 0 - this._outboundTopicAliases = null + this._outboundTopicAliases.clear() this._inboundTopicAliases.clear() if (this._keepaliveTimer) { diff --git a/lib/handlers/connect.js b/lib/handlers/connect.js index 7bb81ef3..b7ac0c6f 100644 --- a/lib/handlers/connect.js +++ b/lib/handlers/connect.js @@ -321,12 +321,13 @@ function init (client, packet, done) { // property; the receiveMaximum/authenticationData Protocol Errors above stay strict // because those affect correctness, not a bandwidth optimisation. const advertisedTopicAliasMax = packet.properties?.topicAliasMaximum + // Just the on/off switch + effective cap; the Map itself is allocated once in the + // Client constructor and stays empty until write() assigns the first alias. `max` + // is min(client's advertised Topic Alias Maximum, broker option), 0 when either is + // absent (feature off for this connection). client._outboundTopicAliasMaximum = packet.protocolVersion === 5 && Number.isInteger(advertisedTopicAliasMax) && advertisedTopicAliasMax > 0 ? Math.min(advertisedTopicAliasMax, client.broker.outboundTopicAliasMaximum) : 0 - if (client._outboundTopicAliasMaximum > 0) { - client._outboundTopicAliases = new Map() - } // MQTT 5.0 Request Problem Information [MQTT-3.1.2-29]: when false the broker // must not send a Reason String or User Properties on packets other than // PUBLISH/CONNACK/DISCONNECT. mqtt-packet decodes the byte property as a @@ -343,8 +344,12 @@ function init (client, packet, done) { // fans out to other subscribers, an attacker-chosen value would hijack an alias // slot in their tables. The write path scrubs it defensively, but drop it at the // source too — the inbound-PUBLISH analogue lives at publish.js resolveTopicAlias. + // Assign `undefined` rather than `delete`, matching the sibling scrubs at + // publish.js:180 / write.js:29: the will `properties` object is reference-shared + // into every fanout packet, and `delete` would transition it into dictionary mode, + // deopting property access for all recipients. if (client._will?.properties?.topicAlias !== undefined) { - delete client._will.properties.topicAlias + client._will.properties.topicAlias = undefined } runSeries( diff --git a/lib/write.js b/lib/write.js index af8928af..60cee313 100644 --- a/lib/write.js +++ b/lib/write.js @@ -65,8 +65,17 @@ function withOutboundTopicAlias (client, packet, version) { return { ...packet, topic: '', properties: { ...packet.properties, topicAlias: known } } } if (aliases.size >= max) { - // Alias table full — send the full topic name, no alias (never evict). Scrub a - // stray caller alias. + // Alias table full: this is a new distinct topic (a known one returned above), + // so send the full topic name, no alias (never evict). Emit a one-shot telemetry + // event the first time the table saturates for this connection — otherwise the + // broker silently degrades to full-topic PUBLISHes with no signal, the case the + // sessionLimitReached / rejectPacketTooLarge convention exists for. Latched on + // the client so it fires once per connection, not per PUBLISH. + if (!client._outboundTopicAliasExhausted) { + client._outboundTopicAliasExhausted = true + client.broker.emit('outboundTopicAliasExhausted', client, { max }) + } + // Scrub a stray caller alias. return callerAlias ? stripTopicAlias(packet) : packet } // New topic: assign the next alias (1..max) and send the full topic + alias, diff --git a/test/mqtt5.js b/test/mqtt5.js index b277f505..47fcb062 100644 --- a/test/mqtt5.js +++ b/test/mqtt5.js @@ -320,6 +320,37 @@ test('MQTT 5.0 outbound Topic Alias: a full alias table falls back to the full t 'a cached topic still reuses its alias after the table filled') }) +test('MQTT 5.0 outbound Topic Alias: table exhaustion emits outboundTopicAliasExhausted once per connection', async (t) => { + t.plan(3) + // Silent degradation is the risk: once the per-connection table fills, further + // distinct topics quietly revert to full-topic PUBLISHes. Emit a one-shot event so + // an operator can spot an outboundTopicAliasMaximum set too low. Effective max here + // is min(client 1, broker 5) = 1. + const { broker, connect } = await createServerAndConnect(t, { brokerOptions: { outboundTopicAliasMaximum: 5 } }) + const events = [] + broker.on('outboundTopicAliasExhausted', (client, info) => events.push({ id: client.id, info })) + const sub = connect({ clientId: 'oalias-exhaust', properties: { topicAliasMaximum: 1 } }) + await once(sub, 'connect') + const publishes = [] + sub.on('packetreceive', p => { if (p.cmd === 'publish') publishes.push(p) }) + await sub.subscribeAsync('e/#', { qos: 0 }) + const pub = connect({ clientId: 'oalias-exhaust-pub' }) + await once(pub, 'connect') + await pub.publishAsync('e/a', '1') // fills the single slot (alias 1) + await waitFor(() => publishes.length >= 1, 'first delivery') + t.assert.equal(events.length, 0, 'not emitted while the table still has room') + await pub.publishAsync('e/b', '2') // new topic, table full -> exhausted + await pub.publishAsync('e/c', '3') // another new topic -> must NOT re-emit (latched) + await waitFor(() => publishes.length >= 3, 'later deliveries') + await delay(20) + t.assert.deepEqual(events, [{ id: 'oalias-exhaust', info: { max: 1 } }], 'emitted exactly once, carrying the effective max') + // A cached topic re-publish after exhaustion must not emit again either. + await pub.publishAsync('e/a', '4') + await waitFor(() => publishes.length >= 4, 'cached re-delivery') + await delay(20) + t.assert.equal(events.length, 1, 'still exactly one event (reusing a cached alias does not re-trip)') +}) + test('MQTT 5.0 outbound Topic Alias: a client advertising above the broker cap is clamped to it', async (t) => { t.plan(2) // The broker option bounds the effective max even when the client advertises far diff --git a/test/types/aedes.test-d.ts b/test/types/aedes.test-d.ts index af3050f5..b0b380e3 100644 --- a/test/types/aedes.test-d.ts +++ b/test/types/aedes.test-d.ts @@ -159,6 +159,12 @@ expectType( (client: Client, info: { reason: 'sessionExpiry' | 'willDelay'; limit: number }) => {} ) ) +expectType( + broker.on( + 'outboundTopicAliasExhausted', + (client: Client, info: { max: number }) => {} + ) +) expectType( broker.on('willDropped', (client: Client, will: NonNullable) => {}) ) diff --git a/tools/mqtt-compat/run_compat.py b/tools/mqtt-compat/run_compat.py index 0e817945..f9428757 100644 --- a/tools/mqtt-compat/run_compat.py +++ b/tools/mqtt-compat/run_compat.py @@ -392,10 +392,25 @@ def main(): regressions.append((r["label"], name, "absent")) if name in EXPECTED_GAPS.get(proto, {}) or name in HARNESS_LIMITED.get(proto, {}): regressions.append((r["label"], name, "also-in-gaps/harness-limited")) + # A whole PROTOCOL can vanish too: the loops above only visit protocols that + # produced a report, so a protocol that carries EXPECTED_PASSES gates but never + # ran (a `--protocols` subset, a run_protocol crash) has every one of its gates + # checked zero times and the job stays green. Diff the gated protocols against the + # ones actually present and fail on any that are missing entirely. + present_protocols = {r["protocol"] for r in reports} + for proto in EXPECTED_PASSES: + if proto not in present_protocols: + regressions.append((proto, "", "protocol-absent")) if regressions: listed = ", ".join(f"{name} [{status}] ({label})" for label, name, status in regressions) print(f"REGRESSION (expected-pass gate): {listed}", file=sys.stderr) + # Surface it as a GitHub Actions error annotation too, matching the + # unexpected-pass step in .github/workflows/mqtt-compat.yml — a plain stderr + # line renders in the log as an indistinguishable failure marker, so an + # operator can't tell a hard-gated regression from an ordinary WIP gap + # without digging. (No-op prefix in a plain terminal.) + print(f"::error title=MQTT compat: expected-pass regression::{listed}") if regressions or xpass: sys.exit(1) diff --git a/types/instance.d.ts b/types/instance.d.ts index f4020438..a9520c3c 100644 --- a/types/instance.d.ts +++ b/types/instance.d.ts @@ -89,8 +89,8 @@ export interface AedesOptions { trustProxy?: boolean; trustedProxies?: string[]; // MQTT 5.0 broker limits, advertised in CONNACK. - topicAliasMaximum?: number; // max inbound topic alias; 0 disables (default: 0) - outboundTopicAliasMaximum?: number; // broker-side cap on outbound topic aliases per connection; opt-in, 0 disables (default: 0) + topicAliasMaximum?: number; // max inbound topic alias; 0 disables (default: 0). A non-integer/negative value coerces to 0 (disabled), a value above 65535 clamps to 65535. + outboundTopicAliasMaximum?: number; // broker-side cap on outbound topic aliases per connection; opt-in, 0 disables (default: 0). A non-integer/negative value (e.g. the string '64') coerces to 0 (disabled, never silently enabled), a value above 65535 clamps to 65535. maximumPacketSize?: number; // max accepted packet size in bytes; 0 = no limit (default: 0) receiveMaximum?: number; // advertised max in-flight QoS 1/2; 0 = not advertised (default: 0) sessionExpiryIntervalLimit?: number; // clamp (seconds) on requested Session Expiry Interval; 0 = no cap (default: 0) @@ -127,6 +127,11 @@ export class Aedes extends EventEmitter { ) => void ): this + on ( + event: 'outboundTopicAliasExhausted', + listener: (client: Client, info: { max: number }) => void + ): this + on ( event: 'willDropped', listener: (client: Client, will: NonNullable) => void