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`;