From fe530355ab3ef14647306ebe98c5d7f080954d9e Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Mon, 18 May 2026 18:05:45 +0530 Subject: [PATCH 1/2] fix(auth): validate OAuth state parameter in GitHub and Google callbacks 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. --- apps/backend/src/routes/auth.ts | 44 ++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index e12f10a..580bf98 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -20,6 +20,15 @@ export async function authRoutes(app: FastifyInstance) { const clientState = (request.query as any).state || ''; const state = clientState ? `${clientState}_${generateState()}` : generateState(); + // Store state in a short-lived cookie so the callback can verify it + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 10 * 60, + }); + const params = new URLSearchParams({ client_id: (process.env.GITHUB_CLIENT_ID || '').trim(), redirect_uri: redirectUri, @@ -27,13 +36,20 @@ export async function authRoutes(app: FastifyInstance) { state, }); const authUrl = `${GITHUB_AUTH_URL}?${params}`; - console.log('--- GITHUB OAUTH REDIRECT ---'); - console.log('URL:', authUrl); return reply.redirect(authUrl); }); app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const { code } = request.query; + const { code, state } = request.query; + + // Validate state to prevent CSRF attacks + const storedState = (request.cookies as any).oauth_state; + reply.clearCookie('oauth_state', { path: '/' }); + + if (!storedState || !state || state !== storedState) { + return reply.status(400).send({ error: 'Invalid OAuth state parameter.' }); + } + if (!code) { return reply.status(400).send({ error: 'Missing authorization code' }); } @@ -146,6 +162,15 @@ export async function authRoutes(app: FastifyInstance) { const clientState = (request.query as any).state || ''; const state = clientState ? `${clientState}_${generateState()}` : generateState(); + // Store state in a short-lived cookie so the callback can verify it + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 10 * 60, + }); + const params = new URLSearchParams({ client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(), redirect_uri: redirectUri, @@ -155,13 +180,20 @@ export async function authRoutes(app: FastifyInstance) { access_type: 'offline', }); const authUrl = `${GOOGLE_AUTH_URL}?${params}`; - console.log('--- GOOGLE OAUTH REDIRECT ---'); - console.log('URL:', authUrl); return reply.redirect(authUrl); }); app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const { code } = request.query; + const { code, state } = request.query; + + // Validate state to prevent CSRF attacks + const storedState = (request.cookies as any).oauth_state; + reply.clearCookie('oauth_state', { path: '/' }); + + if (!storedState || !state || state !== storedState) { + return reply.status(400).send({ error: 'Invalid OAuth state parameter.' }); + } + if (!code) { return reply.status(400).send({ error: 'Missing authorization code' }); } From 457c118d7a54937201cbfbeae05215330ad9490b Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Tue, 19 May 2026 08:39:38 +0530 Subject: [PATCH 2/2] fix(security): authenticate connect callback and replace Math.random 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. --- apps/backend/src/routes/auth.ts | 4 +++- apps/backend/src/routes/connect.ts | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 580bf98..1f236f9 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import crypto from 'crypto'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -319,5 +320,6 @@ export async function authRoutes(app: FastifyInstance) { } function generateState(): string { - return Math.random().toString(36).substring(2, 15); + // Math.random() is not a CSPRNG -- use crypto.randomBytes for OAuth state. + return crypto.randomBytes(32).toString('hex'); } diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index 952e845..9b2936e 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import crypto from 'crypto'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -52,7 +53,9 @@ export async function connectRoutes(app: FastifyInstance) { return reply.redirect(`${GITHUB_AUTH_URL}?${params}`); }); - app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + app.get('/github/callback', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; if (!code || !state) { @@ -66,12 +69,18 @@ export async function connectRoutes(app: FastifyInstance) { if (!decodedState) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } - const userId = decodedState.userId; - if (!userId) { + // Verify the userId embedded in the state matches the authenticated session. + // Without this check an attacker who obtains any valid GitHub OAuth code can + // craft an arbitrary state payload (base64-encoded JSON) pointing to a victim's + // userId and have the server store the attacker's token under the victim's account. + 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; + // Exchange code for token const tokenRes = await fetch(GITHUB_TOKEN_URL, { method: 'POST', @@ -170,5 +179,6 @@ function parseGoogleState(state: string): ParsedOAuthState | null { } function generateState(): string { - return Math.random().toString(36).substring(2, 15); + // Math.random() is not a CSPRNG -- use crypto.randomBytes for OAuth state. + return crypto.randomBytes(32).toString('hex'); }