You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Threat model: closed-group cluster — pubsub mediator is reachable by every local actor and accepts gossip from peers. A buggy or malicious actor can spam Subscribe to a single topic; a peer can claim "I have N remote subscribers for topic X" without bound.
Affected files
src/cluster/pubsub/DistributedPubSubMediator.ts:33-38 — SubscriberSet.local: Map<string, ActorRef> and SubscriberSet.remoteNodes: Set<string> — no cap on either.
This issue and #137 are structurally identical — both are cluster-wide registries with subscribers that grow without bound. The fix shape is the same:
Per-key/topic subscriber cap.
Total-subscriber cap across all keys/topics.
DeathWatch + auto-cleanup.
New SubscribeRejected-style message for caller feedback.
Metric for ops visibility.
Consider coordinated implementation — share helper types (BoundedSubscriberRegistry<K>) between Receptionist and PubSubMediator to avoid divergence. Track separately as a refactor candidate after both ship.
PubSub has two unbounded growth axes: local subscribers per topic, AND distinct topics (no automatic GC of topics with zero local + zero remote subscribers — there is a cleanup at handleUnsubscribe but not for topics that never had subscribers cleared).
PubSub has a remoteNodes set per topic — a malicious gossip peer can claim it has subscribers for arbitrary new topics, ballooning the topic count.
Publish fan-out cost scales with subscriber count → bounded subs is also a publish-latency guarantee.
Background
DistributedPubSubMediator holds topics: Map<string, SubscriberSet>, where each set has:
local: Map<string, ActorRef> — local actors that subscribed.
remoteNodes: Set<string> — remote nodes claiming at least one subscriber on this topic.
The handlers:
privatehandleSubscribe(msg: Subscribe): void{
const set=this.getOrCreateSet(msg.topic);constkey=msg.ref.path.toString();if(!set.local.has(key)){set.local.set(key,msg.ref);// <-- no capthis.version++;changed=true;}// ...}
getOrCreateSet(topic) creates a new entry for any unknown topic — no cap on topic count either. A loop of mediator.tell(new Subscribe('topic-' + i, ref)) for i = 1..1M creates 1M topics in the map.
The gossip handler (in the same file) receives PubSubGossipMsg which lists { topic: string, hasLocalSubscribers: boolean } per topic; for each one with hasLocalSubscribers: true, the receiver adds the gossip sender to topics.get(topic).remoteNodes. A malicious peer gossiping 100K fake topics adds 100K topic entries on every receiver.
Publish fan-out:
privatehandlePublish(msg: Publish): void{
const set=this.topics.get(msg.topic);if(!set)return;// fan-out: tell every local subscriber, and one envelope per remote nodefor(constrefofset.local.values())ref.tell(msg.body);for(constnodeofset.remoteNodes)this.cluster.transport.send(NodeAddress.parse(node),wireMsg);}
Cost per publish: O(local subscribers + remote nodes). At 1M subscribers, every publish is a 1M-iteration loop in the hot path.
Per-topic explosion: a worker that spawns sessions per HTTP request subscribes on preStart; if cleanup is forgotten (or the actor dies before Unsubscribe), the subscriber sticks until the mediator restarts. After 100K requests → 100K subs on notifications topic. Publish to that topic takes ~50ms (1M-iteration fan-out × cluster.transport.send batching).
Topic explosion: another worker subscribes to per-user topics ('user-' + userId); attacker iterates userId from 1..1M. The mediator's topics map balloons to 1M entries. Gossip ticks now serialise 1M topic names (~30MB per gossip message). Cluster bandwidth saturated.
Step 3 — Cascade: mediator's mailbox fills (publishes take ms each instead of µs), supervisor sees a slow actor, restarts it → all subscribers lost → every session re-subscribes simultaneously → thundering-herd. Publish-subscribe is effectively unavailable for minutes.
Realistic worst case: broadcast/notification infrastructure unavailable for entire pubsub-using app. Combined with #137 (Receptionist), the cluster's discovery + broadcast layer is unusable.
How the 8 already-landed security fixes inform this
Wire-frame DoS cap — bound a growth vector at the boundary. Same shape: cap per-topic subs and total topic count.
Hello-handshake hijack defence — verify identity. Same shape: validate gossip-claimed remote subscribers (rate-limit per sender; reject implausible counts).
Snapshot seq integrity — strict bound on input. Same shape.
Track 1 — Per-topic local-subscriber cap (primary). Default: 1024. Same shape as #137.
exportinterfaceDistributedPubSubSettings{readonlycluster: Cluster;readonlygossipIntervalMs?: number;readonlymaxSubscribersPerTopic?: number;// default 1024readonlymaxTopics?: number;// default 8192readonlymaxRemoteNodesPerTopic?: number;// default 256 (cluster size bound)}privatehandleSubscribe(msg: Subscribe): void{
const set=this.getOrCreateSet(msg.topic);if(set===null){// topic cap reached at getOrCreateSetmsg.replyTo?.tell(newSubscribeRejected(msg.topic,'topic-cap-reached'));return;}if(set.local.size>=this.maxSubscribersPerTopic){this.log.warn(`[pubsub] subscriber cap (${this.maxSubscribersPerTopic}) reached for topic "${msg.topic}"`);msg.replyTo?.tell(newSubscribeRejected(msg.topic,'subscriber-cap-reached'));return;}// ... existing add logic ...}privategetOrCreateSet(topic: string): SubscriberSet|null{lets=this.topics.get(topic);if(s)returns;if(this.topics.size>=this.maxTopics){this.log.warn(`[pubsub] topic cap (${this.maxTopics}) reached; rejecting new topic "${topic}"`);this.metrics.counter('pubsub_topic_rejected_total',{reason: 'cap-reached'}).inc();returnnull;}s={local: newMap(),remoteNodes: newSet()};this.topics.set(topic,s);returns;}
Track 2 — DeathWatch + auto-cleanup. Same as #137. Mediator watches each subscriber on accept; on Terminated, removes from all topics + drops empty topics.
Track 3 — Gossip-claimed-remote-subscribers validation. In handleGossip, reject gossip from a sender that claims subscribers on >maxTopicsClaimedPerSender topics (default 1024) — the legitimate case where one node has subscribers on >1024 topics is rare; an attacker generating fake topics is the adversarial case.
Track 4 — Remote-nodes-per-topic cap.maxRemoteNodesPerTopic (default = cluster size cap, e.g. 256). Reject gossip claiming a new remote node for a topic if the cap is reached. Combined with #138's cluster-member cap, this is double-bounded.
Track 5 — Empty-topic GC. When a topic has zero local subscribers AND zero remote nodes, drop it from topics. Already done in handleUnsubscribe but not on gossip path → add to gossip merge logic.
- DistributedPubSub: 1024 subscribers per topic + 8192 distinct
topics by default. Reaching either cap returns SubscribeRejected.
Gossip-claimed remote subscribers also capped per sender.
Severity / Size
Subscribeto a single topic; a peer can claim "I have N remote subscribers for topic X" without bound.Affected files
src/cluster/pubsub/DistributedPubSubMediator.ts:33-38—SubscriberSet.local: Map<string, ActorRef>andSubscriberSet.remoteNodes: Set<string>— no cap on either.src/cluster/pubsub/DistributedPubSubMediator.ts:103-122—handleSubscribeadds unconditionally.src/cluster/pubsub/DistributedPubSubMediator.ts:57—topics: Map<string, SubscriberSet>— no cap on distinct topics either.src/cluster/pubsub/DistributedPubSubMediator.ts(gossip handler — same file) — gossip adds remote-node entries unconditionally.Relationship to #137 (Receptionist)
This issue and #137 are structurally identical — both are cluster-wide registries with subscribers that grow without bound. The fix shape is the same:
SubscribeRejected-style message for caller feedback.Consider coordinated implementation — share helper types (
BoundedSubscriberRegistry<K>) between Receptionist and PubSubMediator to avoid divergence. Track separately as a refactor candidate after both ship.The differences vs #137:
handleUnsubscribebut not for topics that never had subscribers cleared).Background
DistributedPubSubMediatorholdstopics: Map<string, SubscriberSet>, where each set has:local: Map<string, ActorRef>— local actors that subscribed.remoteNodes: Set<string>— remote nodes claiming at least one subscriber on this topic.The handlers:
getOrCreateSet(topic)creates a new entry for any unknown topic — no cap on topic count either. A loop ofmediator.tell(new Subscribe('topic-' + i, ref))for i = 1..1M creates 1M topics in the map.The gossip handler (in the same file) receives
PubSubGossipMsgwhich lists{ topic: string, hasLocalSubscribers: boolean }per topic; for each one withhasLocalSubscribers: true, the receiver adds the gossip sender totopics.get(topic).remoteNodes. A malicious peer gossiping 100K fake topics adds 100K topic entries on every receiver.Publish fan-out:
Cost per publish: O(local subscribers + remote nodes). At 1M subscribers, every publish is a 1M-iteration loop in the hot path.
Exploit walkthrough
Step 1 — App author uses pubsub for fan-out:
Step 2 — Two failure paths simultaneously:
preStart; if cleanup is forgotten (or the actor dies beforeUnsubscribe), the subscriber sticks until the mediator restarts. After 100K requests → 100K subs onnotificationstopic. Publish to that topic takes ~50ms (1M-iteration fan-out × cluster.transport.send batching).'user-' + userId); attacker iterates userId from 1..1M. The mediator'stopicsmap balloons to 1M entries. Gossip ticks now serialise 1M topic names (~30MB per gossip message). Cluster bandwidth saturated.Step 3 — Cascade: mediator's mailbox fills (publishes take ms each instead of µs), supervisor sees a slow actor, restarts it → all subscribers lost → every session re-subscribes simultaneously → thundering-herd. Publish-subscribe is effectively unavailable for minutes.
Realistic worst case: broadcast/notification infrastructure unavailable for entire pubsub-using app. Combined with #137 (Receptionist), the cluster's discovery + broadcast layer is unusable.
How the 8 already-landed security fixes inform this
BoundedSubscriberRegistry<K>helper covers both.Fix design
Track 1 — Per-topic local-subscriber cap (primary). Default: 1024. Same shape as #137.
Track 2 — DeathWatch + auto-cleanup. Same as #137. Mediator watches each subscriber on accept; on
Terminated, removes from all topics + drops empty topics.Track 3 — Gossip-claimed-remote-subscribers validation. In
handleGossip, reject gossip from a sender that claims subscribers on >maxTopicsClaimedPerSendertopics (default 1024) — the legitimate case where one node has subscribers on >1024 topics is rare; an attacker generating fake topics is the adversarial case.Track 4 — Remote-nodes-per-topic cap.
maxRemoteNodesPerTopic(default = cluster size cap, e.g. 256). Reject gossip claiming a new remote node for a topic if the cap is reached. Combined with #138's cluster-member cap, this is double-bounded.Track 5 — Empty-topic GC. When a topic has zero local subscribers AND zero remote nodes, drop it from
topics. Already done inhandleUnsubscribebut not on gossip path → add to gossip merge logic.Track 6 — Metric.
pubsub_subscribers_total{topic}Gauge (bucketed via #131's cap) +pubsub_topics_totalGauge +pubsub_topic_rejected_totalCounter.Track 7 — Documentation. README "Known security caveats":
API surface
Backward compatibility
Behaviour change for apps with >1024 subscribers per topic OR >8192 distinct topics. Opt-out via
Infinity. Document.Test plan
SubscribeRejected('subscriber-cap-reached').SubscribeRejected('topic-cap-reached').Acceptance criteria
DistributedPubSubSettingsexposesmaxSubscribersPerTopic,maxTopics,maxRemoteNodesPerTopic.SubscribeRejectedmessage class exported (shareable with [Security] Receptionist subscribers set is unbounded #137).BoundedSubscriberRegistry<K>helper with Receptionist.