From a9cfc50b52893c953a164803c63e40656380344f Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Tue, 19 May 2026 07:00:09 +0530 Subject: [PATCH] fix(security): bind link-github OAuth state to authenticated session The OAuth state generated by /api/auth/link-github was a random nonce with no binding to the authenticated user's session. An attacker who places their state cookie on a victim's browser (via XSS, cookie tossing, or a shared device) could redirect the victim to the callback URL with the attacker's OAuth code and state. The callback would pass the cookie/state match check, then link the attacker's secondary GitHub account to the victim's devtrack profile under the victim's session. Fix: embed session.githubId into the state as .. The callback now extracts the embedded ID after the cookie/state match and verifies it equals session.githubId. A state generated for user A is therefore rejected when the callback runs under user B's session, regardless of whether the cookie and URL param match. Files changed: - src/app/api/auth/link-github/route.ts: state now includes githubId - src/app/api/auth/link-github/callback/route.ts: verifies embedded ID --- src/app/api/auth/link-github/callback/route.ts | 13 +++++++++++++ src/app/api/auth/link-github/route.ts | 9 ++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/link-github/callback/route.ts b/src/app/api/auth/link-github/callback/route.ts index 8da4a2b..54c8276 100644 --- a/src/app/api/auth/link-github/callback/route.ts +++ b/src/app/api/auth/link-github/callback/route.ts @@ -44,6 +44,19 @@ export async function GET(req: NextRequest) { ); } + // Verify the state was generated for this session's user. + // The state format is `.` (set in the initiation handler). + // Without this check, an attacker who places their state cookie on a + // victim's browser can trick the callback into linking the attacker's + // secondary GitHub account to the victim's devtrack profile. + const embeddedGithubId = state.split(".").slice(1).join("."); + if (!embeddedGithubId || embeddedGithubId !== session.githubId) { + return NextResponse.redirect( + buildSettingsRedirect("error", "invalid_state"), + { status: 302 } + ); + } + const redirectUri = `${process.env.NEXTAUTH_URL ?? ""}/api/auth/link-github/callback`; const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { diff --git a/src/app/api/auth/link-github/route.ts b/src/app/api/auth/link-github/route.ts index e992e25..1e1d61d 100644 --- a/src/app/api/auth/link-github/route.ts +++ b/src/app/api/auth/link-github/route.ts @@ -13,7 +13,14 @@ export async function GET() { ); } - const state = randomBytes(32).toString("hex"); + // Bind the random nonce to the authenticated session by appending the + // session user's GitHub ID. The callback verifies that the ID embedded + // in the state matches the session that completes the flow, so a + // state cookie placed on a different user's browser (e.g. via XSS or + // cookie tossing) cannot be used to link an attacker's GitHub account + // to the victim's devtrack profile. + const nonce = randomBytes(32).toString("hex"); + const state = `${nonce}.${session.githubId}`; const baseUrl = process.env.NEXTAUTH_URL ?? ""; const redirectUri = `${baseUrl}/api/auth/link-github/callback`;