Repository navigation
Fix broadcasting private-channel auth bypass and /broadcasting/auth 404 - #314
Conversation
The Reverb server subscribed any socket to private and presence channels without checking auth, and the empty-path WebSocket mount shadowed /broadcasting/auth and every route added after boot. The auth endpoint now returns a Pusher HMAC signature over socket_id and channel, the server verifies it on subscribe, and both endpoints are plain router routes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCf9PvS7udMZZFFz55EhPo
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
ADR 035 is taken by the config subclass ADR in another open PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgHs6PRFJehnb1BbCNtLww
claude-tmgbedu
left a comment
There was a problem hiding this comment.
Security review + QA of 55594c0: CHANGES NEEDED
The F3 route fix works. The F4 fix blocks the simple cases (no auth, forged auth, another socket's signature, tampered presence channel_data). It can still be bypassed without logging in, though, because the signed string is ambiguous.
🔴 Critical: signature confusion lets an anonymous client into any private/presence channel
signature.py:11 joins socket_id:channel[:channel_data] with : and doesn't constrain any of the parts. Two other choices make that exploitable:
provider.py:117:/broadcasting/authaccepts any client-suppliedsocket_idstring, colons included.provider.py:131plusauth.pyauthorize(): the endpoint signs public channels for anonymous users, because public channels are always allowed.server.py:47:channel_datafrom the client is passed toverify()forprivate-channels too, not onlypresence-.
Repro (real uvicorn plus a websockets client, no user attached):
- Connect and read your socket id
R. POST /broadcasting/authwithchannel_name=newsandsocket_id=R:private-orders.1. You get 200 and a signature overR:private-orders.1:news.- Subscribe to
private-orders.1with thatauthandchannel_data: "news". You getsubscription_succeeded, and every later broadcast onprivate-orders.1reaches the attacker.presence-room.vipfalls the same way.
Fix (all three are cheap; do at least the first two):
- Validate
socket_idstrictly in the endpoint, either the server's UUID format or Pusher's^\d+\.\d+$, and reject channel names containing:. - Use
channel_datainverifyonly forpresence-channels. Never use it forprivate-. - Return 403 from
/broadcasting/authfor non-private/presence channels, as Laravel does. Nothing legitimate needs a signature for a public channel. - Add a regression test for the repro above.
🟠 High: the fallback secret "secret" is accepted
config.py:11 (env("REVERB_APP_SECRET", "secret")) and provider.py:42: if REVERB_APP_SECRET is unset, anyone can mint valid signatures with the public default. I confirmed that a self-signed sign("local","secret",sid,"private-orders.1") subscribes. The server should refuse to start, or reject every private subscribe, when the secret is unset, empty or the default, at least outside local/testing.
🟡 Medium: malformed frames crash the socket handler and skip cleanup
server.py:31-33: only WebSocketDisconnect is caught. A non-JSON frame, "data": "x" or a non-dict message raises (JSONDecodeError/AttributeError, traceback in the log). The socket then stays in ConnectionManager and ChannelManager forever, so a client can leak memory by repeating it. Put the cleanup in a finally and drop or ignore bad frames. The receive size limit is the ASGI server's default (uvicorn: 16 MB).
🟡 Low
provider.py:113: a JSON body that isn't an object ([1]) returns 500. It should return 422.- ADR: the file is
036-…, but its heading says# 035.and thedocs/adr/AGENTS.mdrow is[035](035-broadcasting-channel-auth.md), which is a broken link. - Presence
user_idfalls back to""when the callback allows aNoneuser. Consider 403 when there's no user on presence channels. - Pre-existing, out of scope: no
pusher:unsubscribehandling,client-*events are silently dropped (so nothing leaks), and the socket id is a UUID rather than Pusher'sn.nformat (pusher-js still works). - CSRF/CORS: the endpoint accepts form posts. Cross-site requests can't read the signature unless CORS is permissive, so it's fine as is. Worth a note in the docs.
What passed
uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3308 passed, 10 skipped, 91.46% coverage.tests/broadcasting: 88 passed.- On a live server: subscribing with no auth or a forged auth gets
subscription_error401. An anonymous/broadcasting/authfor a private channel gets 403. Public channels still subscribe./broadcasting/authand routes added after boot are reachable (F3 fixed).compare_digestis used, and channel prefixes are case-sensitive in both the endpoint and the server, which is consistent.
…ames - Socket ids are Pusher digits.digits; /broadcasting/auth and verify reject anything else, and channel names must use the Pusher charset (no ':') - channel_data is signed and verified only for presence- channels - /broadcasting/auth returns 403 for public channels, anonymous users and presence users without an id; 400/422 for malformed bodies - REVERB_APP_SECRET has no default; the provider refuses to boot outside local/testing with an empty or "secret" value, and an empty secret rejects every protected subscription - Malformed or binary frames get pusher:error, oversized frames close with 1009, and connections are always cleaned up - ADR 036 updated with the threat model; index row fixed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgHs6PRFJehnb1BbCNtLww
claude-tmgbedu
left a comment
There was a problem hiding this comment.
Security re-review + QA of 9d6d0ef: APPROVE
All the earlier findings are fixed and verified on a live uvicorn server (APP_ENV=local, strong secret, websockets + httpx clients). I found no new blocking issues.
Earlier findings, re-run
| Check | Result |
|---|---|
Colon injection, private (socket_id=<R>:private-orders.1 on a public channel) |
/broadcasting/auth 422 socket_id is invalid |
| Colon injection, presence | 422 |
: in channel name |
422 |
Private subscribe with a valid signature plus junk channel_data |
Succeeds, but channel_data is ignored for private-, so the confusion is gone |
/broadcasting/auth for a public channel (anonymous or user) |
403 |
| Anonymous request for private/presence | 403 |
| Signature replayed from another socket / reused on another channel | subscription_error 401 |
Forged with "secret" or "" |
401 |
Presence: missing channel_data or spoofed user_id |
401. Valid signed data subscribes |
Bad JSON, data:"x", binary, JSON list, non-string event |
pusher:error 4200, and the socket stays usable (ping → pong) |
| 20 000-char frame | pusher:error, then close 1009 |
| Cleanup | connections/channels back to 0/0 after close, and after 50 sockets each sent a garbage frame and subscribed |
| HTTP: malformed JSON / non-object / non-string channel | 400 / 422 / 422 |
Fresh adversarial pass
- Socket id bypasses:
1.2\n, Arabic-Indic digits,1.2.3,-1.2,1e3.2and1.2all return 422.fullmatchandre.ASCIIclose the$-newline and Unicode-digit gaps. Ids come fromsecrets.randbelow(10**10), and the server verifies against the connection's own id. - Channel names: full-width digit, Cyrillic homoglyph, trailing
\n, leading space and over 200 chars are rejected with 422 on/broadcasting/authand 400 on subscribe.Private-…(different case) is a separate public channel and doesn't receiveprivate-broadcasts. That matches Pusher. - Secret guard: production/staging/
Production/LOCALwith the secret unset,""or"secret"raiseRuntimeErrorat register. Production with a real secret boots. In local with an empty secret, all private auth is denied, so it fails closed. If APP_ENV is unset and there's no.env, the framework already raises before providers run. Note:PYTEST_CURRENT_TESTin the environment forcestestingand therefore skips the guard. That behaviour is pre-existing in the framework and needs control of the environment, so it's informational only. - Echo/pusher-js: the channel regex and 200-char limit match Pusher. The auth request (form
socket_id/channel_name), the response shape, theconnection_establisheddata string and thepusher:error/subscription_errorpayloads all match the protocol. - AGENTS.md: the diff adds no comments or docstrings. ADR 036's heading and index row are fixed.
Non-blocking follow-ups (Low / out of scope)
pusher:unsubscribeisn't handled, so after Echoleave()the socket keeps receiving that channel until it disconnects. This is pre-existing, but it matters more now that private channels are gated.- There's no cap on subscriptions per socket. A client can subscribe to unbounded public channel names (memory grows until it disconnects). Consider a limit.
- Presence
subscription_succeededsendsdata: {}. pusher-js expects{presence:{ids,hash,count}}, so Echohere()/members won't work. This is a functionality gap, not a security one. max_message_sizeis checked only after the ASGI server has buffered the frame (uvicornws_max_sizedefaults to 16 MB). Consider documenting--ws-max-size.
Tests
uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3355 passed, 10 skipped, coverage 91.51%.tests/broadcasting: 135 passed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Summary
ReverbServersubscribed any socket toprivate-/presence-channels without checkingauth, so any WebSocket client could read private broadcasts./broadcasting/authnow requiressocket_idand returns a Pusher signature,key:HMAC-SHA256(secret, socket_id:channel[:channel_data]). The server checks it withcompare_digestand repliespusher:subscription_error(401) when it fails. Presence channels return signedchannel_datathat containsuser_id.Mountshadowed/broadcasting/authand every route added after boot. Both endpoints are now plain router routes (add_websocket_route/add_route), so route order no longer matters. This also fixes the unresolvedRequestannotation.broadcasting.connections.reverb.Breaking
/broadcasting/authresponse changes from{"auth": true}to{"auth": "<key>:<sig>"}. Clients must send that signature inpusher:subscribe; unsigned private/presence subscriptions are now rejected.socket_idis now required for/broadcasting/auth. Laravel Echo already sends it.Test plan
uv run pytest tests/broadcastingpasses (88 tests). The tests run real HTTP and WebSocket traffic through the fully booted app: route reachability in either provider order, unsigned/forged/wrong-socket/wrong-secret subscribes rejected with no broadcasts delivered, signed subscribes receiving broadcasts, and tampered presence data rejected.ruff checkandruff format --checkare clean.Security review follow-up (9d6d0ef)
Addresses #314 (review).
digits.digits(secrets.randbelow)./broadcasting/authandverifyreject any other socket id, and channel names outside the Pusher charset, including any:.channel_datais signed and verified only forpresence-channels./broadcasting/authreturns 403 for public channels and anonymous users. The server verifies against the connection's own socket id. Regression tests replay the reviewer's attack onprivate-orders.1andpresence-room.vip; before the fix both ended insubscription_succeeded.REVERB_APP_SECREThas no default. The provider raises at boot outsidelocal/testingwhen the secret is empty or"secret", and an empty secret rejects every protected subscription and auth request.pusher:error(4200) and the connection stays open. Frames over 10 000 characters close with 1009. Connection and subscription cleanup runs infinally, and empty channels are dropped.Tests:
uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3355 passed, 91.51% coverage. ruff and basedpyright are clean on broadcasting. A live check on uvicorn with awebsocketsclient: the attack gets 422 and thensubscription_error, and the connection count returns to 0.🤖 Generated with Claude Code
https://claude.ai/code/session_01QgHs6PRFJehnb1BbCNtLww