Skip to content

fix(security): authenticate connect callback and replace Math.random OAuth state#162

Open
anshul23102 wants to merge 3 commits into
Dev-Card:mainfrom
anshul23102:fix/connect-callback-auth-bypass
Open

fix(security): authenticate connect callback and replace Math.random OAuth state#162
anshul23102 wants to merge 3 commits into
Dev-Card:mainfrom
anshul23102:fix/connect-callback-auth-bypass

Conversation

@anshul23102
Copy link
Copy Markdown

Fixes #161

Changes

1. Add preHandler: [app.authenticate] to the connect callback (connect.ts)

The callback route now requires a valid session. After decoding the state, the embedded userId is compared against the authenticated user's id:

app.get('/github/callback', {
  preHandler: [app.authenticate],
}, async (request, reply) => {
  ...
  const sessionUserId = (request.user as any).id;
  if (!decodedState.userId || decodedState.userId !== sessionUserId) {
    return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=invalid_state`);
  }
  const userId = decodedState.userId;

This closes the account-takeover path where an attacker with any valid OAuth code could forge a state payload pointing to a victim's userId.

2. Replace Math.random() with crypto.randomBytes (auth.ts + connect.ts)

// Before
return Math.random().toString(36).substring(2, 15);

// After
return crypto.randomBytes(32).toString('hex'); // 256-bit CSPRNG

Applied to both generateState() functions. No logic change -- only the entropy source changes.

Why the preHandler approach works

The JWT is stored in an httpOnly token cookie (set at login). GitHub redirects the user's browser back to the callback URL, which carries all cookies, so app.authenticate can verify the session from the cookie in the same way as every other protected route.

Testing

  1. Log in as User A.
  2. Navigate to GET /api/connect/github -- redirects to GitHub.
  3. Complete GitHub OAuth -- callback lands correctly and stores token under User A. (passes)
  4. Attempt to replay with a forged state containing User B's userId while authenticated as User A -- server returns invalid_state. (blocked)

Draft -- waiting for assignment on #161 before review is requested.

The state parameter was generated before redirecting to GitHub/Google
but was never checked in the callbacks. This made CSRF protection
non-functional since any forged callback request would proceed.

Fix: store the generated state in a short-lived httpOnly cookie before
redirecting, then compare it against the state returned by the provider
in the callback. If they do not match the request is rejected with 400.
The cookie is cleared immediately after the check regardless of outcome.
…state

Two related security issues in the GitHub OAuth connect flow:

1. The /api/connect/github/callback route had no preHandler. An attacker who
   obtained any valid GitHub OAuth code could craft an arbitrary base64 state
   payload containing a victim's userId and POST the callback to store their
   GitHub token under the victim's account, enabling them to trigger follows
   on behalf of the victim.

   Fix: add preHandler: [app.authenticate] and verify that the userId
   embedded in the state matches the authenticated session before the token
   upsert is performed.

2. Both auth.ts and connect.ts used Math.random().toString(36) for OAuth state
   tokens. Math.random is not a CSPRNG (only ~42 bits of entropy in V8) and
   cannot be safely used for security-sensitive nonces.

   Fix: replace with crypto.randomBytes(32).toString('hex') (256 bits of
   cryptographically secure entropy) in both files.
Use named import { randomBytes } from crypto to align with upstream
style. Logic is unchanged -- crypto.randomBytes(32) was already the
correct fix on both branches.

Signed-off-by: anshul23102 <anshul23102@iiitd.ac.in>
@ShantKhatri ShantKhatri requested a review from Harxhit May 20, 2026 10:05
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.

[Security]: GitHub connect callback has no auth -- userId is fully attacker-controlled

1 participant