From 344607d7c9ea406b9fa4b132188eadb683fed052 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 03:16:07 +0000 Subject: [PATCH] fix: add CSRF state validation to Slack and GitHub OAuth flows The Slack and GitHub OAuth callbacks lacked state parameter validation, making them vulnerable to CSRF attacks (OWASP A5). An attacker could trick a logged-in user into connecting an attacker-controlled Slack or GitHub account. Both flows now generate a random state token stored in an httpOnly cookie, and the callback verifies it before exchanging the authorization code. https://claude.ai/code/session_01EG1HcH5DWijTkARenutW5N --- app/api/auth/github/callback/route.ts | 12 +++++++++++- app/api/auth/github/route.ts | 12 ++++++++++++ app/api/auth/slack/callback/route.ts | 12 +++++++++++- app/api/auth/slack/route.ts | 12 ++++++++++++ package.json | 2 +- 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/api/auth/github/callback/route.ts b/app/api/auth/github/callback/route.ts index 6a1ef73..435680a 100644 --- a/app/api/auth/github/callback/route.ts +++ b/app/api/auth/github/callback/route.ts @@ -4,6 +4,7 @@ import { cookies } from "next/headers" export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const code = searchParams.get("code") + const state = searchParams.get("state") const error = searchParams.get("error") const origin = request.nextUrl.origin @@ -13,6 +14,16 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL("/?github_error=" + error, origin)) } + const cookieStore = await cookies() + const savedState = cookieStore.get("github_oauth_state")?.value + + if (!state || state !== savedState) { + console.error("[GitHub] State mismatch") + return NextResponse.redirect(new URL("/?github_error=state_mismatch", origin)) + } + + cookieStore.delete("github_oauth_state") + if (!code) { return NextResponse.redirect(new URL("/?github_error=no_code", origin)) } @@ -46,7 +57,6 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL("/?github_error=no_access_token", origin)) } - const cookieStore = await cookies() cookieStore.set("github_token", accessToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", diff --git a/app/api/auth/github/route.ts b/app/api/auth/github/route.ts index ce0175f..4359e8a 100644 --- a/app/api/auth/github/route.ts +++ b/app/api/auth/github/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server" +import { cookies } from "next/headers" export async function GET(request: NextRequest) { const clientId = process.env.GITHUB_CLIENT_ID @@ -9,11 +10,22 @@ export async function GET(request: NextRequest) { const redirectUri = `${request.nextUrl.origin}/api/auth/github/callback` const scope = "repo read:user" + const state = crypto.randomUUID() + + const cookieStore = await cookies() + cookieStore.set("github_oauth_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 300, + path: "/", + }) const githubAuthUrl = new URL("https://github.com/login/oauth/authorize") githubAuthUrl.searchParams.set("client_id", clientId) githubAuthUrl.searchParams.set("scope", scope) githubAuthUrl.searchParams.set("redirect_uri", redirectUri) + githubAuthUrl.searchParams.set("state", state) return NextResponse.redirect(githubAuthUrl.toString()) } diff --git a/app/api/auth/slack/callback/route.ts b/app/api/auth/slack/callback/route.ts index dc82a77..7952162 100644 --- a/app/api/auth/slack/callback/route.ts +++ b/app/api/auth/slack/callback/route.ts @@ -4,6 +4,7 @@ import { cookies } from "next/headers" export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const code = searchParams.get("code") + const state = searchParams.get("state") const error = searchParams.get("error") const origin = request.nextUrl.origin @@ -13,6 +14,16 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL("/?slack_error=" + error, origin)) } + const cookieStore = await cookies() + const savedState = cookieStore.get("slack_oauth_state")?.value + + if (!state || state !== savedState) { + console.error("[Slack] State mismatch") + return NextResponse.redirect(new URL("/?slack_error=state_mismatch", origin)) + } + + cookieStore.delete("slack_oauth_state") + if (!code) { return NextResponse.redirect(new URL("/?slack_error=no_code", origin)) } @@ -46,7 +57,6 @@ export async function GET(request: NextRequest) { } // Store token in HTTP-only cookie - const cookieStore = await cookies() cookieStore.set("slack_token", userToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", diff --git a/app/api/auth/slack/route.ts b/app/api/auth/slack/route.ts index 5a62f6a..073aae8 100644 --- a/app/api/auth/slack/route.ts +++ b/app/api/auth/slack/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server" +import { cookies } from "next/headers" export async function GET(request: NextRequest) { const clientId = process.env.SLACK_CLIENT_ID @@ -9,11 +10,22 @@ export async function GET(request: NextRequest) { const redirectUri = `${request.nextUrl.origin}/api/auth/slack/callback` const scopes = ["search:read", "users:read", "im:read"].join(",") + const state = crypto.randomUUID() + + const cookieStore = await cookies() + cookieStore.set("slack_oauth_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 300, + path: "/", + }) const slackAuthUrl = new URL("https://slack.com/oauth/v2/authorize") slackAuthUrl.searchParams.set("client_id", clientId) slackAuthUrl.searchParams.set("user_scope", scopes) slackAuthUrl.searchParams.set("redirect_uri", redirectUri) + slackAuthUrl.searchParams.set("state", state) return NextResponse.redirect(slackAuthUrl.toString()) } diff --git a/package.json b/package.json index 8e3cec4..c918776 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "2.0.18", + "version": "2.0.19", "private": true, "scripts": { "dev": "next dev",