Component: src/io/broker/MqttActor.ts
Severity (assessment): INFORMATIONAL
MqttSubscribeCommand is part of the actor's public mailbox type and is handled by registerSubscription with no restriction on the topic filter or on which registry entries may be mutated. The author deliberately hardened the unsubscribe direction so an external controller cannot silence the subclass's own subscription, but left the subscribe direction fully open: an external command can add a fan-out target to an existing pattern (including the subclass's own) and, via last-writer-wins QoS, force a broker re-SUBSCRIBE at a lower quality of service.
Exploit walkthrough
Preconditions: an MqttActor whose ActorRef is reachable by another actor — via the receptionist, an ActorSelection, or simply having been passed around. In a clustered deployment that includes a peer node, so this holds even under full mTLS where one valid member should still not be able to help itself to another member's broker feed.
- The holder sends
{ kind: 'subscribe', topic: '#', target: attackerRef }.
registerSubscription('#', { target: attackerRef }) creates a registry entry and, because the connection is up, issues a broker SUBSCRIBE # at the default QoS.
routeInbound (MqttActor.ts:248-265) matches '#' against every inbound topic and tells the full message — topic, payload bytes, user properties — to attackerRef. The entire topic tree the broker will serve this client is now mirrored to an actor that was never granted it. Nothing logs the new subscription.
- Separately,
{ kind: 'subscribe', topic: <the subclass's own pattern>, qos: 0 } takes the else if (options.qos !== undefined) branch, overwrites entry.qos, and re-issues brokerSubscribe(topic, 0) — silently downgrading the subclass's QoS-2 subscription to at-most-once, so messages the application believes are exactly-once are now droppable. unsubscribe is guarded against exactly this class of interference; subscribe is not.
Severity is LOW because the precondition is possession of the ref, which within a single trusted process is not a boundary — the interesting case is the clustered one, and the QoS downgrade, which is an integrity effect on the owner's own configuration rather than a capability grant.
Evidence — src/io/broker/MqttActor.ts
src/io/broker/MqttActor.ts:269-289 — no filter validation, no distinction between an internal and an external caller:
private registerSubscription(
topic: string,
options: { qos?: MqttQos; target?: ActorRef<MqttMessage<T>> },
): void {
let entry = this.registry.get(topic);
if (!entry) {
entry = { qos: options.qos, deliverToSelf: false, targets: new Set() };
this.registry.set(topic, entry);
} else if (options.qos !== undefined) {
entry.qos = options.qos; // last-writer-wins when a QoS is given
}
if (options.target) {
entry.targets.add(options.target);
this.watchTarget(options.target, topic);
} else {
entry.deliverToSelf = true;
}
if (this.connectionState === 'connected' && this.client) {
this.brokerSubscribe(topic, entry.qos);
}
}
reached from the external command path at src/io/broker/MqttActor.ts:238-240:
private onSubscribe(command: MqttSubscribeCommand<T>): void {
this.registerSubscription(command.topic, { qos: command.qos, target: command.target });
}
The asymmetry is explicit — src/io/broker/MqttActor.ts:291-297:
/**
* @param fromExternal true for an external `unsubscribe` command with
* no target — drops all foreign targets but keeps the actor's own
* subscription (a controller must not be able to silence the
* subclass's constructor-declared subscription). false for the
* protected `unsubscribe(topic)` — drops only the own delivery.
*/
and matchesMqttPattern (src/io/broker/MqttActor.ts:547-558) implements the full wildcard grammar, so '#' matches every topic:
if (patternSegment === '#') return true;
Why the existing guard does not cover it
removeSubscription(topic, undefined, fromExternal=true) (MqttActor.ts:298-318) correctly refuses to clear deliverToSelf, so the subclass's own delivery survives external tampering — the threat model "a controller must not be able to silence the subclass" is explicitly present in the code. watchTarget/removeTerminatedTarget correctly clean up when a foreign target dies, so there is no leak. What is missing is any counterpart on the additive side: no allow-list of subscribable filters, no hook for the subclass to vet an incoming subscribe, and no protection of an existing entry's QoS.
Suggested fix
Add an overridable protected allowExternalSubscribe(topic: string, target?: ActorRef<…>): boolean (default true for compatibility, or default to "only patterns already in the registry") and consult it in onSubscribe. Make the QoS write in registerSubscription conditional on the caller: an external command should be able to set QoS only for an entry it created, never overwrite one the subclass declared. Log at info when an external subscribe introduces a new broker-level filter, so an unexpected # is visible.
Verification status
Found in the second, independent whole-framework security re-audit of 2026-08-02 (v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.
Verifier note
The code reads as described. onSubscribe (src/io/broker/MqttActor.ts:238-240) forwards an external command straight into registerSubscription (:269-289) with no filter validation and no internal/external distinction; the else if (options.qos !== undefined) { entry.qos = options.qos; } branch overwrites an existing entry's QoS and is followed by an unconditional brokerSubscribe(topic, entry.qos) while connected. routeInbound (:248-265) fans every matching entry's targets, and matchesMqttPattern (:547-558) returns true for '#' against any topic (if (patternSegment === '#') return true;). The asymmetry the finding leans on is genuine and self-documented: removeSubscription's JSDoc at :291-297 states that "a controller must not be able to silence the subclass's constructor-declared subscription", and the fromExternal branch (:305-311) honours it by clearing foreign targets while leaving deliverToSelf intact.
But the finding's primary claim is not a gap — it is the actor's advertised contract. The class JSDoc at :69-72 explicitly says the actor "is still externally controllable" and that a subscribe command with a target fans out to that actor. Anyone holding the ref can also { kind: 'publish' } to any topic, which is at least as powerful as reading one, so there is no capability escalation in the '#' scenario; the precondition is possession of an ActorRef, which inside a single process is not a trust boundary, and the finder concedes as much. The residual, code-confirmed defect is narrow but real: an external subscribe naming an existing pattern can rewrite that entry's QoS and force a re-SUBSCRIBE at the lower value, so a subclass that declared QoS 2 silently degrades to at-most-once — an integrity effect on the owner's own configuration that violates exactly the invariant the mirror-image unsubscribe path was hardened to protect. That is worth an issue as a consistency fix (gate the QoS write on the caller, add an overridable allowExternalSubscribe hook, log when an external subscribe introduces a new broker-level filter), so I kept it at INFORMATIONAL rather than refuting.
Correction applied: The headline capability is documented design, not a defect. MqttActor's own class JSDoc states it at src/io/broker/MqttActor.ts:69-72: "It is still externally controllable: ref.tell(cmd) with a MqttCommand publishes / subscribes / unsubscribes; a subscribe command with no target routes to this actor's own onMessage, with a target fans out to that actor." Attaching a target to a filter is therefore the actor's intended public command surface, and a holder of the ref already has { kind: 'publish' } to arbitrary topics — an equal-or-greater capability — so the '#' fan-out grants nothing the ref did not already grant. What survives is only the QoS half: registerSubscription's last-writer-wins overwrite of an existing entry's QoS, followed by an unconditional re-SUBSCRIBE, which lets an external command silently downgrade the subclass's constructor-declared QoS-2 subscription to QoS-0. Severity corrected LOW → INFORMATIONAL.
Component:
src/io/broker/MqttActor.tsSeverity (assessment): INFORMATIONAL
MqttSubscribeCommandis part of the actor's public mailbox type and is handled byregisterSubscriptionwith no restriction on the topic filter or on which registry entries may be mutated. The author deliberately hardened the unsubscribe direction so an external controller cannot silence the subclass's own subscription, but left the subscribe direction fully open: an external command can add a fan-out target to an existing pattern (including the subclass's own) and, via last-writer-wins QoS, force a broker re-SUBSCRIBE at a lower quality of service.Exploit walkthrough
Preconditions: an
MqttActorwhoseActorRefis reachable by another actor — via the receptionist, anActorSelection, or simply having been passed around. In a clustered deployment that includes a peer node, so this holds even under full mTLS where one valid member should still not be able to help itself to another member's broker feed.{ kind: 'subscribe', topic: '#', target: attackerRef }.registerSubscription('#', { target: attackerRef })creates a registry entry and, because the connection is up, issues a brokerSUBSCRIBE #at the default QoS.routeInbound(MqttActor.ts:248-265) matches'#'against every inbound topic andtells the full message — topic, payload bytes, user properties — toattackerRef. The entire topic tree the broker will serve this client is now mirrored to an actor that was never granted it. Nothing logs the new subscription.{ kind: 'subscribe', topic: <the subclass's own pattern>, qos: 0 }takes theelse if (options.qos !== undefined)branch, overwritesentry.qos, and re-issuesbrokerSubscribe(topic, 0)— silently downgrading the subclass's QoS-2 subscription to at-most-once, so messages the application believes are exactly-once are now droppable.unsubscribeis guarded against exactly this class of interference;subscribeis not.Severity is LOW because the precondition is possession of the ref, which within a single trusted process is not a boundary — the interesting case is the clustered one, and the QoS downgrade, which is an integrity effect on the owner's own configuration rather than a capability grant.
Evidence —
src/io/broker/MqttActor.tssrc/io/broker/MqttActor.ts:269-289— no filter validation, no distinction between an internal and an external caller:reached from the external command path at
src/io/broker/MqttActor.ts:238-240:The asymmetry is explicit —
src/io/broker/MqttActor.ts:291-297:and
matchesMqttPattern(src/io/broker/MqttActor.ts:547-558) implements the full wildcard grammar, so'#'matches every topic:Why the existing guard does not cover it
removeSubscription(topic, undefined, fromExternal=true)(MqttActor.ts:298-318) correctly refuses to cleardeliverToSelf, so the subclass's own delivery survives external tampering — the threat model "a controller must not be able to silence the subclass" is explicitly present in the code.watchTarget/removeTerminatedTargetcorrectly clean up when a foreign target dies, so there is no leak. What is missing is any counterpart on the additive side: no allow-list of subscribable filters, no hook for the subclass to vet an incomingsubscribe, and no protection of an existing entry's QoS.Suggested fix
Add an overridable
protected allowExternalSubscribe(topic: string, target?: ActorRef<…>): boolean(defaulttruefor compatibility, or default to "only patterns already in the registry") and consult it inonSubscribe. Make the QoS write inregisterSubscriptionconditional on the caller: an external command should be able to set QoS only for an entry it created, never overwrite one the subclass declared. Log at info when an externalsubscribeintroduces a new broker-level filter, so an unexpected#is visible.Verification status
Found in the second, independent whole-framework security re-audit of 2026-08-02 (
v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.Verifier note
The code reads as described.
onSubscribe(src/io/broker/MqttActor.ts:238-240) forwards an external command straight intoregisterSubscription(:269-289) with no filter validation and no internal/external distinction; theelse if (options.qos !== undefined) { entry.qos = options.qos; }branch overwrites an existing entry's QoS and is followed by an unconditionalbrokerSubscribe(topic, entry.qos)while connected.routeInbound(:248-265) fans every matching entry's targets, andmatchesMqttPattern(:547-558) returns true for'#'against any topic (if (patternSegment === '#') return true;). The asymmetry the finding leans on is genuine and self-documented:removeSubscription's JSDoc at :291-297 states that "a controller must not be able to silence the subclass's constructor-declared subscription", and thefromExternalbranch (:305-311) honours it by clearing foreign targets while leavingdeliverToSelfintact.But the finding's primary claim is not a gap — it is the actor's advertised contract. The class JSDoc at :69-72 explicitly says the actor "is still externally controllable" and that a
subscribecommand with atargetfans out to that actor. Anyone holding the ref can also{ kind: 'publish' }to any topic, which is at least as powerful as reading one, so there is no capability escalation in the'#'scenario; the precondition is possession of anActorRef, which inside a single process is not a trust boundary, and the finder concedes as much. The residual, code-confirmed defect is narrow but real: an externalsubscribenaming an existing pattern can rewrite that entry's QoS and force a re-SUBSCRIBE at the lower value, so a subclass that declared QoS 2 silently degrades to at-most-once — an integrity effect on the owner's own configuration that violates exactly the invariant the mirror-image unsubscribe path was hardened to protect. That is worth an issue as a consistency fix (gate the QoS write on the caller, add an overridableallowExternalSubscribehook, log when an external subscribe introduces a new broker-level filter), so I kept it at INFORMATIONAL rather than refuting.Correction applied: The headline capability is documented design, not a defect.
MqttActor's own class JSDoc states it at src/io/broker/MqttActor.ts:69-72: "It is still externally controllable:ref.tell(cmd)with a MqttCommand publishes / subscribes / unsubscribes; asubscribecommand with notargetroutes to this actor's ownonMessage, with atargetfans out to that actor." Attaching a target to a filter is therefore the actor's intended public command surface, and a holder of the ref already has{ kind: 'publish' }to arbitrary topics — an equal-or-greater capability — so the'#'fan-out grants nothing the ref did not already grant. What survives is only the QoS half:registerSubscription's last-writer-wins overwrite of an existing entry's QoS, followed by an unconditional re-SUBSCRIBE, which lets an external command silently downgrade the subclass's constructor-declared QoS-2 subscription to QoS-0. Severity corrected LOW → INFORMATIONAL.