fix(websocket): heartbeat disconnects healthy clients; auth and rate-limit bypass - #9
Open
frankstupak wants to merge 1 commit into
Open
fix(websocket): heartbeat disconnects healthy clients; auth and rate-limit bypass#9frankstupak wants to merge 1 commit into
frankstupak wants to merge 1 commit into
Conversation
…g before built-ins, close auth bypass, 281x broadcast - Heartbeat was broken in both directions: the server never handled PONG (app-level or protocol-level) and the frontend hook never answered PING, so every correctly-behaving client was force-disconnected at pingTimeout. Now: PONG handler + protocol pong listener + any-message activity refresh on the server; pong reply (echoing server timestamp for RTT) in the hook. - Dead connections are now actually terminated (ws terminate / socket.io disconnect) instead of just forgotten with the socket left open. - New onBeforeMessage gate hook runs before built-in handlers; rate limiting moved there — previously a rate-limited client could still flood rooms via room_message because built-ins ran before the onMessage hook. - validateToken accepted ANY 'Bearer *' string; now constant-time compare against configurable authTokens. - GET /api/history?offset=0 returned an empty page (string '0' truthy -> slice(-limit, -0)); query params now coerced. - Broadcast serializes once instead of once per recipient: 16.9ms -> 0.060ms per 5k-client 1KB broadcast (281x, bench included). - Socket.IO clients now get structured emit (their send() method made the emit branch dead code; consumers received JSON strings). - Reconnect uses exponential backoff + 50-100% jitter, capped at 30s. - sendToClient checks readyState; messagesPerSecond reports real completed windows and decays; honest addClientToRoom returns; heartbeat noise kept out of the hook's message history; stop() no longer leaks the cleanup interval on never-started servers; fixed shipped ReferenceError test (undeclared client1Id/client2Id) that burned its full 90s timeout. - +19 tests (88 total, was 68 passing/1 failing); suite 184s -> 6.5s.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The headline
The heartbeat reaps every client that follows the protocol. The manager sends
{type: "ping"}and reaps anyone whoselastPinggoes stale — but there is no PONG handler anywhere inhandleBuiltInMessages, no protocol-levelponglistener, and the shippeduseWebSockethook never answers pings. Net effect: connect the shipped frontend to the shipped backend and every client is force-disconnected atpingTimeout(60s), forever, on a loop. Answering the heartbeat correctly did not help; sending the existing ping was the only survival strategy, and nothing in the repo does that.Fixed end-to-end per the canonical
wspattern (isAlive / pong / terminate — see the ws README):PONGand listens for protocol-levelpongframes (browsers answer those automatically — JS can't see them, so both layers matter){type:"ping"}with a pong echoing the timestamp, so the server can compute RTTAlso fixed
Rate limiting never covered built-ins.
handleBuiltInMessagesruns before theonMessagehook, so a rate-limited client could still flood every room viaroom_messageall day. NewonBeforeMessagegate hook runs before built-ins; rate limiting moved there (legacyonMessagepath preserved, no double-counting — WeakSet-tracked).Auth bypass.
validateTokenreturned true for anything starting with"Bearer ".Authorization: Bearer lolwas a valid credential. Now constant-time compare (crypto.timingSafeEqual) against configurableauthTokens.GET /api/history?offset=0always returned an empty page. Query params are strings over HTTP;offset ? -offset : undefinedtreats"0"as truthy →slice(-limit, -0)→slice(-limit, 0)→[]. Coerced and fixed.Socket.IO delivery was double-serialized. Socket.IO sockets have a
send()method, soif (client.socket.send)routed them down the raw path — theemitbranch was dead code and Socket.IO consumers received JSON strings instead of objects. Detection now keys onnsp.The existing test suite fails as shipped.
basic-websocket-server.test.tsroom-communication test assignsclient1Id/client2Idwithout declaring them — strict-mode ReferenceError inside the message handler, test burns its full 90s timeout. Baseline: 68 passing / 1 failing, 184s. Now: 88/88 in 6.5s.Smaller:
sendToClientchecksreadyStatebefore send (no more console spam on CLOSING sockets) ·messagesPerSecondreported the partial current window and never decayed — now reports real completed windows and drops to 0 when idle ·addClientToRoom/removeClientFromRoomreturnedtruefor clients that don't exist — honest now ·stop()leaked the cleanup interval on constructed-but-never-started servers · heartbeat traffic no longer eats the hook's 100-slot message history · reconnect uses exponential backoff with 50–100% jitter capped at 30s (fixed-interval retries = synchronized thundering-herd waves on server restart).Numbers
Broadcast serialized once per recipient. For a 5,000-client broadcast that's 4,999 redundant
JSON.stringifycalls per message.281x per broadcast. Bench included at
benchmarks/broadcast-bench.ts(npx tsx benchmarks/broadcast-bench.ts).Verification
npm run test:all: all 8 websocket suites green; the 6 failing suites are pre-existing missing-dependency errors in other subprojects (fuse.js / @prisma/client / winston not installed), identical before and after this difftsc --noEmitclean, rooteslint0 problemsonBeforeMessageis additive,authTokensdefaults to the previous"valid-token"+19 tests covering every fix above. Diff confined to
src/api/websocket/.— Lumen Industries