Skip to content

fix(relay): harden the machine doors #2073 put on the hosted relay - #2076

Merged
AbirAbbas merged 3 commits into
devfrom
fix/relay-machine-door-hardening
Oct 30, 2026
Merged

AbirAbbas merged 3 commits into
devfrom
fix/relay-machine-door-hardening

Conversation

@AbirAbbas

Copy link
Copy Markdown
Collaborator

Follow-up to #2073, whose machine doors went live on the production relay on 2026-10-30. A review found them abusable; this fixes every confirmed finding.

What was wrong → what is true now

Was Now
Relay streams had no deadlines, so the 30 s handshake limit was a no-op: 16 silent dials held a machine's every slot, and a lone P byte burned the pairing code and froze pairing muxStream has read/write deadlines; silent dials end at 30 s and the next code is offered
A different key with a colliding name could evict a live machine during a 5 s stall only the same key may replace a silent registration (hosted and self-hosted)
Rate limits lived per name inside each Durable Object: one address could hit unlimited names (no cost ceiling under a flood) per-caller limits at the Worker front ([[ratelimits]], 30 dials / 10 registrations a minute), and only real nameFor names reach an object
serve reset its backoff the instant Register returned (1 s reconnect storm) and printed raw sentinels backoff resets after a registration holds a minute; throttling is a sentence; lost sync is answered by back — reachable as <name> again
The upgrade ignored HTTP(S)_PROXY, leaked a connection per 426, showed raw HTML bodies, read an old relay's 503 as unreachable, dropped the read limit all fixed (review findings 5–9)
serve --help and the manual omitted the saved v3/remote/relay file from relay precedence named

Proof

Hand test — real codeaf serve + chat --at through the hosted Worker under wrangler dev (BASE_PATH=/fabric), attacked with the real relay client:

  • 16 silent dials: 15 got slots (one held by a real --at), a probe at 6 s was refused, all 15 closed by the machine at 30 s, a probe at 46 s got through.
  • One P byte: ended at 30 s, machine said someone typed a wrong code, so that code is no longer good and printed a fresh code.
  • Invalid names (zzz-zzz-99, a-a-00, otter-lamp-05): that is not a machine name at the front door.
  • 40 dials across different real names from one address: refused from the 31st of the minute.
  • Relay stopped 60 s: lost sync — trying again once, no spam; relay back: back — reachable as chalk-candle-50 again 8 s later, and dials reach it.

Tests: relay/hosted 248/248; internal/relay, internal/pair, internal/relayserve, internal/manual, focused cmd/codeaf (Serve|Pair|Relay|At), make test-laws — all green.

Deploy

Additive: one new [[ratelimits]] pair, no migration. After merge, the production relay is redeployed from dev (the automatic per-channel deploy is a separate PR).

🤖 Generated with Claude Code

AbirAbbas and others added 3 commits October 30, 2026 13:45
…ut loud

Review of #2073 found the machine doors abusable once they went live on the
hosted relay. Relay streams had no deadlines, so the serving machine's
30-second handshake limit was a no-op: 16 silent dials held every slot, and a
lone "pair" byte burned the code and froze pairing. muxStream now carries
read/write deadlines, so both end at HandshakeWithin and the next code is
offered.

Also: the self-hosted relay lets only the same key replace a silent
registration; serve backs off until a registration has held a minute, names
a throttled relay in a sentence, and answers "lost sync" with one "back —
reachable as <name> again"; the WebSocket upgrade honours HTTP(S)_PROXY and
leaves no idle connection behind on a 426; a non-JSON or HTML body becomes a
one-sentence fact naming the relay with its base path; an older relay's 503
ceiling reads as busy; the carrier keeps its read limit, checks the
negotiated subprotocol and splits writes over the relay's message ceiling;
serve --help and the manual name the full relay precedence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…their keys

The hosted machine doors kept their rate limits per name, inside each
name's Durable Object, so one address could hit unlimited names and wake an
object for each: no cost ceiling under a deliberate flood. Dials and
registrations are now limited per caller network at the Worker front, with
Cloudflare's rate-limiting binding (30 dials, 10 registrations a minute, the
self-hosted relay's numbers) and a bounded in-memory fallback, and only names
nameFor can produce reach an object at all.

A registration with a different key can no longer evict a live machine
during a stall: only the same key may replace a silent socket. Sends that
can throw are guarded so a dying socket cannot leak a stream slot, and
pending sockets that never prove their key are swept. The room logic moves
to room.js so Node's test runner can exercise it without a Worker runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit 6206dbc into dev Oct 30, 2026
7 checks passed
@AbirAbbas
AbirAbbas deleted the fix/relay-machine-door-hardening branch October 30, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant