fix(connect): validate OAuth state nonce server-side to prevent CSRF (#223)#558
fix(connect): validate OAuth state nonce server-side to prevent CSRF (#223)#558Ridanshi wants to merge 1 commit into
Conversation
|
@Ridanshi is attempting to deploy a commit to the Prashantkumar Khatri's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Hi @Ridanshi, Thanks for opening this pull request. This PR has been automatically classified based on the files modified. Applied Labels
Primary Review Area
Reviewer@Harxhit has been identified as the primary reviewer for this pull request. If you have any questions regarding the affected area or implementation details, feel free to reach out to the assigned reviewer. Thank you for your contribution! |
CI — All Checks PassedBackend — PASS
Mobile — SKIP
Web — SKIP
Last updated: |
47763f7 to
544181d
Compare
Harxhit
left a comment
There was a problem hiding this comment.
Checked this out and tested locally. The core security fix is correct and the test coverage is genuinely thorough — this closes the account-takeover vector cleanly. I do have a couple of things that should be sorted before merge, the main one being that the branch is built on a stale base.
Verified locally: npm run typecheck clean, and npx vitest run src/__tests__/connect.test.ts src/__tests__/oauth-scope.test.ts src/__tests__/follow.test.ts src/__tests__/profiles.test.ts → 52/52 pass.
Verified ✅
- The CSRF vector is actually closed.
userIdno longer travels through the callback — it's looked up server-side from Redis by nonce. A forged callback can no longer bind a victim's account to an attacker credential. - Entropy is sound.
randomBytes(32).toString('hex')= 256-bit nonce, replacingMath.random(). - Strict format validation before any Redis I/O.
parseNonceenforces^[0-9a-f]{64}$and the callback bails before touching Redis on malformed input — confirmed by the "too short", "non-hex", and "forged base64-JSON" tests, all of which assertmockRedis.getis never called. - No bypass path. Traced every branch: missing
code/state→ reject; bad format → reject; nonce not in Redis → reject; corrupt JSON → reject; emptyuserId→ reject. Token exchange is only reachable after a valid, stored nonce. - Single-use / replay protection works. Nonce is deleted after the existence check, and the replay test (second callback, same nonce →
connect_failed) passes. - Expiry via 10-min Redis TTL (
EX 600), asserted in the initiation test. - Fails closed when Redis is down.
app.redisis always decorated by the plugin, so the droppedif (app.redis)guards are safe; a genuine outage makes.get()reject → 500 → no connection made. github_followplatform key correctly prevents the connect token from clobbering the login token.
Suggestions
- [blocker] Rebase onto
main— the branch is stale and conflicts. This branch forked from an olderconnect.ts(~221 lines);mainhas since refactored that file substantially (~383 lines, including its own lint cleanup:node:crypto,catch {}, fixed import order).git merge maincurrently conflicts in bothconnect.tsandconnect.test.ts. Please rebase and re-resolve so the fix lands on top of the current file rather than revertingmain's improvements. Several lint errors below disappear after rebasing. - [security – minor] Use atomic
getdelto close the replay TOCTOU window. The callback doesgetthen a separatedel. Two concurrent callbacks replaying the same nonce can both pass thegetbefore eitherdels.auth.ts:604already usesapp.redis.getdel(...)for exactly this one-time-code pattern — mirroring it here makes consumption atomic:const storedRaw = await app.redis.getdel(`oauth:connect-nonce:${nonce}`); if (!storedRaw) { /* reject */ }
- [lint – should fix] New
parseNonceviolates the repo'scurlyrule (brace-lessifs). Wrap them in braces. - [lint – should fix] Test-harness lint. The modified harnesses trip
import-x/orderandno-param-reassignin bothconnect.test.tsandoauth-scope.test.ts.eslint --fixhandles the import ordering. - [nit] Redis-outage UX. When Redis is unreachable the callback returns a raw
500rather than the friendly…/settings?error=server_errorredirect used elsewhere in this route. Security is fine (fails closed), but wrappingget/delto land back on settings would be more consistent.
Nice work on the test matrix — the forged-state, replay, corrupt-data, and expiry cases are exactly what I'd want on a CSRF fix. Once it's rebased and the getdel + lint items are in, this is good to go.
Reviewed locally on the PR branch: typecheck ✅, 52/52 tests ✅, eslint + merge-base/conflict analysis. No changes pushed.
…loses Dev-Card#223) Replace client-side-only state validation in the GitHub connect flow with server-side nonce verification backed by Redis. - Generate a 32-byte cryptographically secure nonce on connect initiation - Store {userId} in Redis under oauth:connect-nonce:<nonce> with a 10-minute TTL - State parameter carries only the nonce; userId is never trusted from the callback - On callback: validate nonce format, look up Redis, reject unknown/expired nonces - Delete nonce immediately after first successful validation (replay protection) - Drop Math.random()-based state generation in favour of randomBytes(32) - Add 13 tests covering: valid flow, missing params, malformed state, unknown nonce, expired nonce, forged state, replay attack, corrupt Redis data, token exchange failure
544181d to
f29c39e
Compare
|
Rebased onto [blocker] Rebase — Branch is now on top of [security] Atomic [lint] [lint] Test-harness lint — Fixed [nit] Redis outage — Validation on the updated branch:
|
Summary
Closes #223.
The GitHub connect flow (
/api/connect/github) accepteduserIddirectly from the unverifiedstatecallback parameter and usedMath.random()to generate the state value. An attacker could forge a callback with a crafted state, causing the server to associate the victim's account with an attacker-controlled GitHub credential.The login flow (
/auth/github,/auth/google) is addressed separately via the cookie-based double-submit CSRF fix already present onmain(#481).Changes
apps/backend/src/routes/connect.ts/connect/githubinitiation{ userId }in Redis underoauth:connect-nonce:<nonce>with a 10-minute TTLstateparameter —userIdnever travels through the callback URLMath.random()withrandomBytes(32)github_followplatform key to prevent auth-token overwritesapps/backend/src/__tests__/connect.test.tsapps/backend/src/__tests__/oauth-scope.test.tsbuildConnectAppandmakeConnectStateto match new nonce-only state formatSecurity properties
Math.random)randomBytes(32))userIdsource in callbackTests (connect.test.ts — 14 tests)
github_followplatform assertionuserIdand TTLcodeparameterstateparameterTest plan
npx vitest run src/__tests__/connect.test.ts src/__tests__/oauth-scope.test.ts src/__tests__/follow.test.ts src/__tests__/profiles.test.ts— 52/52 passevent.test.tsandanalytics.test.tsare unrelated to this change (upstream test infrastructure issue withrequest.usernot being set by thejwtVerifymock)