Skip to content

Use crypto.randomUUID() for ActionCableLink channelId to prevent collisions#5642

Merged
rmosolgo merged 2 commits into
rmosolgo:masterfrom
obake-fe:fix/action-cable-link-channel-id-collision
May 26, 2026
Merged

Use crypto.randomUUID() for ActionCableLink channelId to prevent collisions#5642
rmosolgo merged 2 commits into
rmosolgo:masterfrom
obake-fe:fix/action-cable-link-channel-id-collision

Conversation

@obake-fe
Copy link
Copy Markdown

Summary

Replace the channelId generator in ActionCableLink to use crypto.randomUUID() so that simultaneously-created subscriptions no longer collide.

Fixes #5639.

Background

The previous channelId generator,

var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16)

draws from only ~100,001 distinct values per millisecond. When multiple useSubscription hooks mount during the same render pass (or simply within a short time window), collisions are inevitable. With N subscriptions opened within ~100 seconds of each other, the collision probability is approximately C(N, 2) / 100_000 (e.g. ~0.05% for N=10, ~0.2% for N=20).

When two subscriptions share the same identifier:

  1. The Rails ActionCable server deduplicates the second subscribe (return if subscriptions.key?(id_key) in action_cable/connection/subscriptions.rb).
  2. The client retains both Subscription objects — ActionCable JS does not deduplicate.
  3. On message arrival, ActionCable JS invokes received on all local subscriptions whose identifier matches.
  4. The "deduplicated" (locally surviving) subscription's listener receives the other subscription's payload, which has the wrong shape, causing Apollo to log Missing field 'X' while writing result {...} warnings and downstream onData handlers to crash with TypeError.

Changes

  • ActionCableLink.ts: replace the time+random channelId with crypto.randomUUID(). Add a short comment explaining why.
  • __tests__/ActionCableLinkTest.ts: add a test that creates 1000 subscriptions in a tight loop and asserts the channelIds are all unique. With the old generator this would fail frequently (collisions are expected when many IDs are drawn within a single ms); with crypto.randomUUID() it always passes.

Compatibility

crypto.randomUUID() is available in:

  • Chrome 92+ (July 2021)
  • Firefox 95+ (December 2021)
  • Safari 15.4+ (March 2022)
  • Node 14.17+ / 16.7+ (and via globalThis.crypto in Node 19+)

If wider compatibility is required I'm happy to add a fallback (e.g. crypto.getRandomValues over a 128-bit buffer, or a module-scoped counter + Date.now).

Test plan

  • npm test (full suite) passes locally — 92 passed / 3 skipped (unchanged from before)
  • New test generates a unique channelId for each subscription passes

The previous channelId generator,
`Math.round(Date.now() + Math.random() * 100000).toString(16)`, had only
~100,000 distinct values per millisecond. When multiple subscriptions are
opened around the same time, occasional collisions are inevitable. When
two subscriptions share the same identifier, the server-side ActionCable
deduplicates the subscribe, but the client retains both Subscription
objects and routes every incoming payload to both `received` callbacks,
leaking one subscription's payload into another's listener.

Switch to `crypto.randomUUID()` (Web Crypto API), which is available in
all evergreen browsers and Node 14.17+, so collisions are effectively
impossible.

Fixes rmosolgo#5639
@rmosolgo rmosolgo added this to the js-1.15.0 milestone May 26, 2026
@rmosolgo
Copy link
Copy Markdown
Owner

Thanks again for taking this on.

wider compatibility

This is a good point -- I doubt anyone will need it, but I imagine a global crypto polyfill would work, too. I added a commit that also accepts an injected channelId: ... option, so if someone needs backwards compatibility (why??), they could acheive it.

@rmosolgo rmosolgo merged commit 59b6fae into rmosolgo:master May 26, 2026
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ActionCableLink: low-entropy channelId causes cross-subscription payload leak

2 participants