fix(ws): fail closed when WS_AUTH_SECRET is unset (#212) - #236
Conversation
`const WS_SECRET = WS_AUTH_SECRET || ""` passed the empty string to
createHmac(). "" is a perfectly usable HMAC key, so an attacker who knows the
secret is unset can compute a valid signature and authenticate.
Production and WS_AUTH_REQUIRED=true both process.exit() at module load, so the
reachable case is a non-production deploy with WS_AUTH_REQUIRED=false: the
`auth` message handler calls verifyWsToken() regardless of whether auth is
required, so a forged token still granted slab binding.
Gates every HMAC operation on WS_SECRET_USABLE:
- generateWsToken() throws rather than mint a forgeable token
- verifyWsToken() returns invalid and logs, instead of verifying against ""
Unauthenticated clients still connect in development: upgrade-time
auto-authentication (`authenticated = !WS_AUTH_REQUIRED`) never consults a
token, so only the forgeable path is removed.
Also corrects the module doc comment, which claimed WS_AUTH_SECRET "falls back
to dev-only default" — it fell back to "", which is what made this exploitable.
Verified by driving a real HTTP + WebSocket server (same harness style as
ws-ip-limits.test.ts) rather than adding a test-only export:
against UNPATCHED src, the 2 vulnerability tests fail —
forged token reply: expected 'error', received 'authenticated'
generateWsToken: expected to throw, did not
against PATCHED src, all 5 pass
The 3 control tests (real secret configured) pass both before and after,
showing legitimate auth is unaffected.
Full suite: 255 -> 260 passed, no new failures. tsc: 26 pre-existing errors
before and after, byte-identical, none introduced. The one failing file
(tests/sdk-smoke.test.ts) fails identically on clean origin/main — it is the
stale local @percolator/shared / SDK, unrelated to this change.
Scope: this addresses the empty-secret half of #212 only. See the PR for why
the IP-binding half needs to be sequenced behind #230/#231.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughWebSocket authentication now fails closed when ChangesWebSocket authentication hardening
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/routes/ws-auth-secret-fail-closed.test.ts (1)
35-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMock
eventBus.offfor defensive test coverage.Although
eventBus.offisn't invoked during this specific test run (becausevi.resetModules()resets the listener variables tonullbeforesetupWebSocketinspects them),src/routes/ws.tsrelies oneventBus.offduring cleanup. Mocking it prevents potential runtime errors if a test manually invokescleanupEventBusListenersor imports the module differently in the future.♻️ Proposed refactor
- eventBus: { on: vi.fn() }, + eventBus: { on: vi.fn(), off: vi.fn() },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/routes/ws-auth-secret-fail-closed.test.ts` at line 35, Extend the eventBus mock in ws-auth-secret-fail-closed.test.ts to include an off vi.fn() method alongside on, so cleanupEventBusListeners can safely call the mocked cleanup API in defensive or alternate test flows.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/routes/ws-auth-secret-fail-closed.test.ts`:
- Line 35: Extend the eventBus mock in ws-auth-secret-fail-closed.test.ts to
include an off vi.fn() method alongside on, so cleanupEventBusListeners can
safely call the mocked cleanup API in defensive or alternate test flows.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a8f2ae6b-8539-432e-80bb-cb606d010494
📒 Files selected for processing (2)
src/routes/ws.tstests/routes/ws-auth-secret-fail-closed.test.ts
Addresses the empty-secret half of #212. The IP-binding half is deliberately not included — reasoning below.
The bug
""is a perfectly usable HMAC key. Anyone who knows the secret is unset can compute a valid signature themselves and authenticate.Production and
WS_AUTH_REQUIRED=truebothprocess.exit()at module load, so the reachable case is a non-production deploy withWS_AUTH_REQUIRED=false: theauthmessage handler callsverifyWsToken()regardless of whether auth is required, so a forged token still granted slab binding.The fix
Gates every HMAC operation on
WS_SECRET_USABLE:generateWsToken()throws rather than mint a token it cannot meaningfully signverifyWsToken()returns invalid and logs, instead of verifying against""No legitimate flow breaks. Unauthenticated clients still connect in development, because upgrade-time auto-authentication (
authenticated = !WS_AUTH_REQUIRED) never consults a token. Only the forgeable path is removed.Also corrects the module doc comment, which claimed the secret "falls back to dev-only default" — there is no such default; it fell back to
"", which is precisely what made this exploitable.How to test
Driven through a real HTTP + WebSocket server (same harness style as
ws-ip-limits.test.ts) rather than adding a test-only export to production code, so the actual reachable path is exercised.The tests were run against unpatched source to confirm they genuinely catch the bug:
origin/mainexpected 'error', received 'authenticated'generateWsTokenrefuses to mintexpected to throw, did notreceived 'authenticated'on unpatched code is the exploit, demonstrated end-to-end over a real socket. The 3 control tests pass both before and after, so legitimate auth is provably unaffected.Suite and typecheck, measured against a clean
origin/mainbaseline:tscerrorsThe one failing file (
tests/sdk-smoke.test.ts) fails identically on cleanorigin/main; it is the stale local@percolator/shared/SDK, unrelated to this change. Note CI will also still be red at install until #233 lands.Why the IP-binding half is NOT here
#212 also asks to bind tokens to the client IP. I looked into it and it should be sequenced, not bundled — three findings that change the calculus:
generateWsTokenhas zero callers. It is exported but never invoked anywhere insrc/ortests/, so tokens are minted by something outside this repo. Adding an IP field makes the token 4 parts and trips theparts.length !== 3check — that is a breaking cross-service format change I cannot verify against an issuer I cannot see. Shipping it blind risks locking out every legitimate WS client.The IP itself is currently spoofable.
getClientIp()trustsX-Forwarded-ForperTRUSTED_PROXY_DEPTH, which is exactly what [HIGH-CRITICAL]Bug: Default TRUSTED_PROXY_DEPTH trusts client-supplied X-Forwarded-For, enabling spoofed-IP bypass of HTTP rate limits, IP blocklist, and WebSocket abuse controls #230 reports and PR fix(security): default TRUSTED_PROXY_DEPTH to fail closed #231 fixes. Binding a token to an attacker-controllable value buys little while adding real breakage risk. fix(security): default TRUSTED_PROXY_DEPTH to fail closed #231 should land first.A naive single-use nonce would self-DoS.
verifyWsToken()is called twice for oneauthmessage — the slab-rebinding peek atws.ts:797and the real check atws.ts:810. Any replay cache must live at the connection level, not insideverifyWsToken.Also worth flagging for whoever picks that up: tokens arrive in the URL query string (
url.searchParams.get("token")), so they leak into access logs, proxies andRefererheaders — which is what makes the replay window worth closing in the first place.I would rather ship the half that is provably correct and complete than half-ship the half that needs a cross-service decision. Happy to take the follow-up once #231 is in.
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests