From b0d28ecb13c5e959c7c9d8037c587238ddd73fb3 Mon Sep 17 00:00:00 2001 From: smartalee Date: Tue, 30 Jun 2026 08:22:33 -0400 Subject: [PATCH 1/6] security: add CSRF protection for state-mutating API routes - Add double-submit cookie CSRF pattern with x-csrf-token header - Implement CSRF middleware with validation for POST/PATCH/DELETE - Exempt auth endpoints and GET/HEAD/OPTIONS from validation - Apply CSRF validation to notes, bookmarks, approvals, and tips routes Closes #723 --- src/app/api/approvals/route.ts | 27 ++++++ src/app/api/bookmarks/route.ts | 40 +++++++++ src/app/api/notes/route.ts | 40 +++++++++ src/app/api/tips/route.ts | 12 +++ src/lib/csrfMiddleware.ts | 158 +++++++++++++++++++++++++++++++++ src/middleware.ts | 121 +++++++++++++++++++++++++ 6 files changed, 398 insertions(+) create mode 100644 src/lib/csrfMiddleware.ts diff --git a/src/app/api/approvals/route.ts b/src/app/api/approvals/route.ts index 16ccaeca..8f65dfe3 100644 --- a/src/app/api/approvals/route.ts +++ b/src/app/api/approvals/route.ts @@ -6,6 +6,7 @@ import { validateBody, validateQuery } from '@/lib/validation'; import { ApprovalStatus } from '@/types/approvals'; import { query } from '@/lib/db/pool'; import type { ApprovalItem, ReviewDecision } from '@/types/api'; +import { getCsrfTokenFromCookies, getCsrfTokenFromHeaders } from '@/lib/csrfMiddleware'; export const runtime = 'nodejs'; @@ -81,6 +82,19 @@ export async function POST(request: Request): Promise { const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) return rateLimitResponse; + // CSRF validation + const cookieToken = getCsrfTokenFromCookies(request as any); + const headerToken = getCsrfTokenFromHeaders(request as any); + + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + return addHeaders( + NextResponse.json( + { success: false, message: 'CSRF token validation failed' }, + { status: 403 } + ) + ); + } + const validation = validateBody(SubmitSchema, await request.json()); if (!validation.ok) return addHeaders(validation.error); @@ -123,6 +137,19 @@ export async function PATCH(request: Request): Promise { const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) return rateLimitResponse; + // CSRF validation + const cookieToken = getCsrfTokenFromCookies(request as any); + const headerToken = getCsrfTokenFromHeaders(request as any); + + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + return addHeaders( + NextResponse.json( + { success: false, message: 'CSRF token validation failed' }, + { status: 403 } + ) + ); + } + const validation = validateBody(ReviewSchema, await request.json()); if (!validation.ok) return addHeaders(validation.error); diff --git a/src/app/api/bookmarks/route.ts b/src/app/api/bookmarks/route.ts index 0ea8031b..91aa7962 100644 --- a/src/app/api/bookmarks/route.ts +++ b/src/app/api/bookmarks/route.ts @@ -3,6 +3,7 @@ import type { VideoBookmark } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; import { logAuditMutation } from '@/middleware/audit'; import { edgeLog } from '@/../infra/edge-config'; +import { getCsrfTokenFromCookies, getCsrfTokenFromHeaders } from '@/lib/csrfMiddleware'; import { validateBody, validateQuery } from '@/lib/validation'; import { @@ -58,6 +59,19 @@ export async function POST(request: Request): Promise path.startsWith(exemptPath))) { + return null; + } + + // Validate CSRF token + if (!validateCsrfToken(request)) { + return NextResponse.json( + { + success: false, + message: 'CSRF token validation failed. Please refresh the page and try again.', + code: 'CSRF_TOKEN_INVALID' + }, + { status: 403 } + ); + } + + return null; +} + +/** + * Create a new CSRF token and set it as a cookie + * Used for initial token generation on page load + */ +export function createCsrfTokenResponse( + request: NextRequest, + options: { secure?: boolean } = {} +): NextResponse { + const token = generateCsrfToken(); + const response = NextResponse.json({ + csrfToken: token, + message: 'CSRF token generated successfully' + }); + return setCsrfCookie(response, token, options.secure); +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 61f4cb46..b3a01d57 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -15,6 +15,24 @@ import { INTERNAL_API_REQUEST_HEADER, } from './lib/apiVersioning'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { checkRoutePermission } from './middleware/rbac'; +import { applySecurityHeaders } from './middleware/security'; +import { applyCspHeaders } from './middleware/csp'; +import { handleRedirects } from './middleware/redirectManagement'; +import { UserRole } from './types/api'; +import { + API_DEPRECATION_HEADER, + API_DEPRECATION_INFO_HEADER, + API_ROOT, + API_VERSION_HEADER, + DEFAULT_API_VERSION, + VERSIONED_API_ROOT, + INTERNAL_API_REQUEST_HEADER, +} from './lib/apiVersioning'; +import { validateCsrfRequest, setCsrfCookie, generateCsrfToken, getCsrfTokenFromCookies } from './lib/csrfMiddleware'; + export function middleware(request: NextRequest) { const traceId = crypto.randomUUID(); request.headers.set('x-trace-id', traceId); @@ -36,6 +54,109 @@ export function middleware(request: NextRequest) { return applyCspHeaders(withSecurity, request); }; + // Check route permissions + const permissionResponse = checkRoutePermission(request, userRole); + if (permissionResponse) { + return withHeaders(permissionResponse); + } + + const { pathname } = request.nextUrl; + + // ====================================================================== + // CSRF Protection for API routes + // ====================================================================== + if (pathname.startsWith('/api/')) { + // Exclude GET requests and auth endpoints from CSRF validation + const csrfValidation = validateCsrfRequest(request, { + skipMethods: ['GET', 'HEAD', 'OPTIONS'], + exemptPaths: [ + '/api/auth/login', + '/api/auth/signup', + '/api/auth/discord', + '/api/auth/email-verification', + '/api/auth/email-verification/resend', + '/api/auth/email-verification/verify', + '/api/auth/email-verification/restore', + '/api/health', + '/api/performance', + ], + }); + + if (csrfValidation) { + return withHeaders(csrfValidation); + } + + // Ensure CSRF cookie exists for GET requests (so client can read it) + if (request.method === 'GET') { + const existingToken = getCsrfTokenFromCookies(request); + if (!existingToken) { + const newToken = generateCsrfToken(); + const response = NextResponse.next(); + setCsrfCookie(response, newToken); + return withHeaders(response); + } + } + } + + // API versioning handling + if (pathname.startsWith(API_ROOT)) { + if (request.headers.get(INTERNAL_API_REQUEST_HEADER) === 'true') { + const response = NextResponse.next(); + response.headers.set(API_VERSION_HEADER, DEFAULT_API_VERSION); + return withHeaders(response); + } + + if (!pathname.startsWith(`${API_ROOT}/v`)) { + const rewriteUrl = request.nextUrl.clone(); + rewriteUrl.pathname = `${VERSIONED_API_ROOT}${pathname.slice(API_ROOT.length)}`; + const response = NextResponse.rewrite(rewriteUrl.toString()); + response.headers.set(API_VERSION_HEADER, DEFAULT_API_VERSION); + response.headers.set(API_DEPRECATION_HEADER, 'true'); + response.headers.set( + API_DEPRECATION_INFO_HEADER, + `This endpoint is deprecated. Use ${VERSIONED_API_ROOT}${pathname.slice( + API_ROOT.length, + )} instead.`, + ); + return withHeaders(response); + } + + const response = NextResponse.next(); + response.headers.set(API_VERSION_HEADER, pathname.split('/')[2] || DEFAULT_API_VERSION); + return withHeaders(response); + } + + return withHeaders(NextResponse.next()); +} + +export const config = { + matcher: [ + '/admin/:path*', + '/instructor/:path*', + '/editor/:path*', + '/dashboard/:path*', + '/profile/:path*', + '/api/:path*', + ], +}; + + // Handle redirects first (early in the chain) + const redirectResponse = handleRedirects(request); + if (redirectResponse) { + redirectResponse.headers.set('x-trace-id', traceId); + return redirectResponse; + } + + // In a real application, you would verify the JWT or session here. + const roleCookie = request.cookies.get('user-role')?.value as UserRole | undefined; + const userRole = roleCookie || null; + + const withHeaders = (response: NextResponse) => { + response.headers.set('x-trace-id', traceId); + const withSecurity = applySecurityHeaders(response, request); + return applyCspHeaders(withSecurity, request); + }; + const permissionResponse = checkRoutePermission(request, userRole); if (permissionResponse) { return withHeaders(permissionResponse); From a14d29eb94abc9ad50c39a101e2fdda93a09bb6c Mon Sep 17 00:00:00 2001 From: smartalee Date: Sun, 12 Jul 2026 10:18:06 -0400 Subject: [PATCH 2/6] fix: resolve merge conflicts and fix workflow --- src/lib/__tests__/csrfMiddleware.test.ts | 152 +++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/lib/__tests__/csrfMiddleware.test.ts diff --git a/src/lib/__tests__/csrfMiddleware.test.ts b/src/lib/__tests__/csrfMiddleware.test.ts new file mode 100644 index 00000000..979709bf --- /dev/null +++ b/src/lib/__tests__/csrfMiddleware.test.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + generateCsrfToken, + getCsrfTokenFromCookies, + getCsrfTokenFromHeaders, + validateCsrfToken, + validateCsrfRequest, + setCsrfCookie, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, +} from '../csrfMiddleware'; + +describe('CSRF Middleware', () => { + describe('generateCsrfToken', () => { + it('generates a 64-character hex string', () => { + const token = generateCsrfToken(); + expect(token).toHaveLength(64); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it('generates different tokens each time', () => { + const token1 = generateCsrfToken(); + const token2 = generateCsrfToken(); + expect(token1).not.toBe(token2); + }); + }); + + describe('validateCsrfToken', () => { + it('returns true when tokens match', () => { + const token = generateCsrfToken(); + const request = new NextRequest('http://localhost/api/test', { + headers: { + [CSRF_HEADER_NAME]: token, + }, + }); + // Set cookie manually + request.cookies.set(CSRF_COOKIE_NAME, token); + + expect(validateCsrfToken(request)).toBe(true); + }); + + it('returns false when tokens do not match', () => { + const token1 = generateCsrfToken(); + const token2 = generateCsrfToken(); + const request = new NextRequest('http://localhost/api/test', { + headers: { + [CSRF_HEADER_NAME]: token1, + }, + }); + request.cookies.set(CSRF_COOKIE_NAME, token2); + + expect(validateCsrfToken(request)).toBe(false); + }); + + it('returns false when cookie token is missing', () => { + const token = generateCsrfToken(); + const request = new NextRequest('http://localhost/api/test', { + headers: { + [CSRF_HEADER_NAME]: token, + }, + }); + + expect(validateCsrfToken(request)).toBe(false); + }); + + it('returns false when header token is missing', () => { + const token = generateCsrfToken(); + const request = new NextRequest('http://localhost/api/test'); + request.cookies.set(CSRF_COOKIE_NAME, token); + + expect(validateCsrfToken(request)).toBe(false); + }); + }); + + describe('validateCsrfRequest', () => { + it('returns null for GET requests (skipped)', () => { + const request = new NextRequest('http://localhost/api/notes', { + method: 'GET', + }); + expect(validateCsrfRequest(request)).toBeNull(); + }); + + it('returns null for HEAD requests (skipped)', () => { + const request = new NextRequest('http://localhost/api/notes', { + method: 'HEAD', + }); + expect(validateCsrfRequest(request)).toBeNull(); + }); + + it('returns null for OPTIONS requests (skipped)', () => { + const request = new NextRequest('http://localhost/api/notes', { + method: 'OPTIONS', + }); + expect(validateCsrfRequest(request)).toBeNull(); + }); + + it('returns null for auth endpoints (exempt)', () => { + const request = new NextRequest('http://localhost/api/auth/login', { + method: 'POST', + }); + expect(validateCsrfRequest(request)).toBeNull(); + }); + + it('returns 403 response when CSRF validation fails for POST', () => { + const request = new NextRequest('http://localhost/api/notes', { + method: 'POST', + headers: { + [CSRF_HEADER_NAME]: 'invalid-token', + }, + }); + request.cookies.set(CSRF_COOKIE_NAME, 'different-token'); + + const result = validateCsrfRequest(request); + expect(result).not.toBeNull(); + expect(result?.status).toBe(403); + }); + + it('returns null when CSRF validation passes for POST', () => { + const token = generateCsrfToken(); + const request = new NextRequest('http://localhost/api/notes', { + method: 'POST', + headers: { + [CSRF_HEADER_NAME]: token, + }, + }); + request.cookies.set(CSRF_COOKIE_NAME, token); + + expect(validateCsrfRequest(request)).toBeNull(); + }); + }); + + describe('setCsrfCookie', () => { + it('sets the CSRF cookie with the correct name', () => { + const token = generateCsrfToken(); + const response = new NextResponse(); + const result = setCsrfCookie(response, token); + + const cookieHeader = result.cookies.toString(); + expect(cookieHeader).toContain(CSRF_COOKIE_NAME); + expect(cookieHeader).toContain(token); + }); + + it('sets httpOnly to false', () => { + const token = generateCsrfToken(); + const response = new NextResponse(); + const result = setCsrfCookie(response, token); + + const cookieHeader = result.cookies.toString(); + expect(cookieHeader).toContain('HttpOnly=false'); + }); + }); +}); \ No newline at end of file From 0710a5bbabe16d6613c811c5218f9eae6f4b4212 Mon Sep 17 00:00:00 2001 From: smartalee Date: Sun, 12 Jul 2026 10:26:54 -0400 Subject: [PATCH 3/6] fix: resolve merge conflict in middleware.ts --- src/middleware.ts | 104 ++++++++-------------------------------------- 1 file changed, 17 insertions(+), 87 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index b3a01d57..dd70f839 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,24 +4,6 @@ import { checkRoutePermission } from './middleware/rbac'; import { applySecurityHeaders } from './middleware/security'; import { applyCspHeaders } from './middleware/csp'; import { handleRedirects } from './middleware/redirectManagement'; -import { UserRole } from './types/api'; -import { - API_DEPRECATION_HEADER, - API_DEPRECATION_INFO_HEADER, - API_ROOT, - API_VERSION_HEADER, - DEFAULT_API_VERSION, - VERSIONED_API_ROOT, - INTERNAL_API_REQUEST_HEADER, -} from './lib/apiVersioning'; - -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; -import { checkRoutePermission } from './middleware/rbac'; -import { applySecurityHeaders } from './middleware/security'; -import { applyCspHeaders } from './middleware/csp'; -import { handleRedirects } from './middleware/redirectManagement'; -import { UserRole } from './types/api'; import { API_DEPRECATION_HEADER, API_DEPRECATION_INFO_HEADER, @@ -33,10 +15,13 @@ import { } from './lib/apiVersioning'; import { validateCsrfRequest, setCsrfCookie, generateCsrfToken, getCsrfTokenFromCookies } from './lib/csrfMiddleware'; -export function middleware(request: NextRequest) { +export async function middleware(request: NextRequest) { const traceId = crypto.randomUUID(); request.headers.set('x-trace-id', traceId); + const cspNonce = crypto.randomUUID(); + request.headers.set('x-csp-nonce', cspNonce); + // Handle redirects first (early in the chain) const redirectResponse = handleRedirects(request); if (redirectResponse) { @@ -45,13 +30,18 @@ export function middleware(request: NextRequest) { } // In a real application, you would verify the JWT or session here. - const roleCookie = request.cookies.get('user-role')?.value as UserRole | undefined; - const userRole = roleCookie || null; + // Verify JWT from Authorization header or cookie — never trust client-supplied role cookies + const token = + request.headers.get('Authorization')?.replace('Bearer ', '') ?? + request.cookies.get('Authorization')?.value; + const { verifyToken } = await import('./lib/auth/jwt'); + const payload = await verifyToken(token); + const userRole = payload?.role ?? null; const withHeaders = (response: NextResponse) => { response.headers.set('x-trace-id', traceId); const withSecurity = applySecurityHeaders(response, request); - return applyCspHeaders(withSecurity, request); + return applyCspHeaders(withSecurity, request, cspNonce); }; // Check route permissions @@ -121,72 +111,12 @@ export function middleware(request: NextRequest) { return withHeaders(response); } - const response = NextResponse.next(); - response.headers.set(API_VERSION_HEADER, pathname.split('/')[2] || DEFAULT_API_VERSION); - return withHeaders(response); - } - - return withHeaders(NextResponse.next()); -} - -export const config = { - matcher: [ - '/admin/:path*', - '/instructor/:path*', - '/editor/:path*', - '/dashboard/:path*', - '/profile/:path*', - '/api/:path*', - ], -}; - - // Handle redirects first (early in the chain) - const redirectResponse = handleRedirects(request); - if (redirectResponse) { - redirectResponse.headers.set('x-trace-id', traceId); - return redirectResponse; - } - - // In a real application, you would verify the JWT or session here. - const roleCookie = request.cookies.get('user-role')?.value as UserRole | undefined; - const userRole = roleCookie || null; - - const withHeaders = (response: NextResponse) => { - response.headers.set('x-trace-id', traceId); - const withSecurity = applySecurityHeaders(response, request); - return applyCspHeaders(withSecurity, request); - }; - - const permissionResponse = checkRoutePermission(request, userRole); - if (permissionResponse) { - return withHeaders(permissionResponse); - } - - const { pathname } = request.nextUrl; - if (pathname.startsWith(API_ROOT)) { - if (request.headers.get(INTERNAL_API_REQUEST_HEADER) === 'true') { - const response = NextResponse.next(); - response.headers.set(API_VERSION_HEADER, DEFAULT_API_VERSION); - return withHeaders(response); - } - - if (!pathname.startsWith(`${API_ROOT}/v`)) { - const rewriteUrl = request.nextUrl.clone(); - rewriteUrl.pathname = `${VERSIONED_API_ROOT}${pathname.slice(API_ROOT.length)}`; - const response = NextResponse.rewrite(rewriteUrl.toString()); - response.headers.set(API_VERSION_HEADER, DEFAULT_API_VERSION); - response.headers.set(API_DEPRECATION_HEADER, 'true'); - response.headers.set( - API_DEPRECATION_INFO_HEADER, - `This endpoint is deprecated. Use ${VERSIONED_API_ROOT}${pathname.slice( - API_ROOT.length, - )} instead.`, - ); - return withHeaders(response); + const extractedVersion = pathname.split('/')[2]; + if (!extractedVersion || !/^v\d+$/.test(extractedVersion)) { + return withHeaders(new NextResponse('Invalid API version', { status: 400 })); } - const response = NextResponse.next(); - response.headers.set(API_VERSION_HEADER, pathname.split('/')[2] || DEFAULT_API_VERSION); + response.headers.set(API_VERSION_HEADER, extractedVersion); return withHeaders(response); } @@ -202,4 +132,4 @@ export const config = { '/profile/:path*', '/api/:path*', ], -}; +}; \ No newline at end of file From c727ef3fa2fc9d3ed1ab13c1fd985dfe434094d6 Mon Sep 17 00:00:00 2001 From: smartalee Date: Sun, 12 Jul 2026 10:30:44 -0400 Subject: [PATCH 4/6] fix: resolve merge conflict in middleware.ts --- src/middleware.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index dd70f839..3c145d73 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -29,8 +29,7 @@ export async function middleware(request: NextRequest) { return redirectResponse; } - // In a real application, you would verify the JWT or session here. - // Verify JWT from Authorization header or cookie — never trust client-supplied role cookies + // Verify JWT from Authorization header or cookie const token = request.headers.get('Authorization')?.replace('Bearer ', '') ?? request.cookies.get('Authorization')?.value; @@ -56,7 +55,6 @@ export async function middleware(request: NextRequest) { // CSRF Protection for API routes // ====================================================================== if (pathname.startsWith('/api/')) { - // Exclude GET requests and auth endpoints from CSRF validation const csrfValidation = validateCsrfRequest(request, { skipMethods: ['GET', 'HEAD', 'OPTIONS'], exemptPaths: [ @@ -76,7 +74,7 @@ export async function middleware(request: NextRequest) { return withHeaders(csrfValidation); } - // Ensure CSRF cookie exists for GET requests (so client can read it) + // Ensure CSRF cookie exists for GET requests if (request.method === 'GET') { const existingToken = getCsrfTokenFromCookies(request); if (!existingToken) { From 19f7470d3914496b62b2de89a811f83963150719 Mon Sep 17 00:00:00 2001 From: smartalee Date: Sun, 12 Jul 2026 10:39:56 -0400 Subject: [PATCH 5/6] fix: resolve merge conflict in middleware.ts --- src/middleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/middleware.ts b/src/middleware.ts index 3c145d73..e52eeec0 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -29,7 +29,7 @@ export async function middleware(request: NextRequest) { return redirectResponse; } - // Verify JWT from Authorization header or cookie + // Verify JWT from Authorization header or cookie — never trust client-supplied role cookies const token = request.headers.get('Authorization')?.replace('Bearer ', '') ?? request.cookies.get('Authorization')?.value; @@ -109,6 +109,7 @@ export async function middleware(request: NextRequest) { return withHeaders(response); } + // Fix for #726 — validate version string before use const extractedVersion = pathname.split('/')[2]; if (!extractedVersion || !/^v\d+$/.test(extractedVersion)) { return withHeaders(new NextResponse('Invalid API version', { status: 400 })); From 181e8b84d318f2235d5e7389c376bab55eb98778 Mon Sep 17 00:00:00 2001 From: smartalee Date: Fri, 31 Jul 2026 10:23:28 -0400 Subject: [PATCH 6/6] feat: add CSRF protection for state-mutating API routes --- src/lib/csrfMiddleware.ts | 220 +++++++++++++++----------------------- src/middleware.ts | 6 ++ 2 files changed, 94 insertions(+), 132 deletions(-) diff --git a/src/lib/csrfMiddleware.ts b/src/lib/csrfMiddleware.ts index 5c6086ae..ea6ed9b1 100644 --- a/src/lib/csrfMiddleware.ts +++ b/src/lib/csrfMiddleware.ts @@ -1,158 +1,114 @@ import { NextRequest, NextResponse } from 'next/server'; import crypto from 'crypto'; -/** - * CSRF Protection using Double-Submit Cookie pattern - * - * How it works: - * 1. Server generates a CSRF token and sets it as a cookie (httpOnly: false) - * 2. Client reads the token from the cookie and sends it in the x-csrf-token header - * 3. Server validates the header matches the cookie value - * 4. If mismatch, returns 403 Forbidden - * - * Why this approach: - * - SameSite=Strict cookies alone are not sufficient for all cross-origin scenarios - * - Double-submit cookie pattern is stateless and works well with Next.js - * - No server-side storage required (tokens are validated against the cookie) - */ - -const CSRF_COOKIE_NAME = 'csrf-token'; -const CSRF_HEADER_NAME = 'x-csrf-token'; +// CSRF Configuration +const CSRF_CONFIG = { + cookieName: 'XSRF-TOKEN', + headerName: 'x-csrf-token', + cookieOptions: { + httpOnly: false, // Client needs to read it for double-submit + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/', + }, + tokenLength: 32, +}; -/** - * Generate a cryptographically secure CSRF token - */ -export function generateCsrfToken(): string { - return crypto.randomBytes(32).toString('hex'); +// Generate a cryptographically secure CSRF token +export function generateCSRFToken(): string { + return crypto.randomBytes(CSRF_CONFIG.tokenLength).toString('hex'); } -/** - * Get the CSRF token from the request cookies - */ -export function getCsrfTokenFromCookies(request: NextRequest): string | undefined { - return request.cookies.get(CSRF_COOKIE_NAME)?.value; +// Get CSRF token from cookie +export function getCSRFTokenFromCookie(request: NextRequest): string | undefined { + return request.cookies.get(CSRF_CONFIG.cookieName)?.value; } -/** - * Get the CSRF token from the request headers - */ -export function getCsrfTokenFromHeaders(request: NextRequest): string | undefined { - return request.headers.get(CSRF_HEADER_NAME) || undefined; +// Check if request is idempotent (safe method) +function isIdempotentMethod(method: string): boolean { + return ['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()); } -/** - * Validate that the CSRF token in the header matches the token in the cookie - */ -export function validateCsrfToken(request: NextRequest): boolean { - const cookieToken = getCsrfTokenFromCookies(request); - const headerToken = getCsrfTokenFromHeaders(request); +// Check if path is an auth endpoint (exempt from CSRF) +function isAuthEndpoint(pathname: string): boolean { + const authPaths = [ + '/api/auth/login', + '/api/auth/signup', + '/api/auth/logout', + '/api/auth/discord', + '/api/auth/email-verification', + ]; + return authPaths.some(path => pathname.startsWith(path)); +} - // Both tokens must exist - if (!cookieToken || !headerToken) { - return false; +// Validate CSRF token using timing-safe comparison +function timingSafeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); } - - // Compare tokens using timing-safe comparison to prevent timing attacks - return crypto.timingSafeEqual( - Buffer.from(cookieToken, 'hex'), - Buffer.from(headerToken, 'hex') - ); + return result === 0; } -/** - * Set the CSRF token cookie on the response - * - * @param response - The NextResponse to set the cookie on - * @param token - The CSRF token to set (optional, generates one if not provided) - * @param secure - Whether to set the cookie as secure (defaults to true in production) - * @returns The response with the CSRF cookie set - */ -export function setCsrfCookie( - response: NextResponse, - token?: string, - secure: boolean = process.env.NODE_ENV === 'production' -): NextResponse { - const csrfToken = token || generateCsrfToken(); - - response.cookies.set({ - name: CSRF_COOKIE_NAME, - value: csrfToken, - httpOnly: false, // Must be false so JavaScript can read it - secure: secure, - sameSite: 'lax', // LAX provides a good balance of security and usability - path: '/', - maxAge: 60 * 60 * 24, // 24 hours - }); +// Main CSRF middleware handler +export function csrfMiddleware(request: NextRequest) { + const method = request.method; + const pathname = request.nextUrl.pathname; - return response; -} + // Skip CSRF validation for idempotent methods + if (isIdempotentMethod(method)) { + return { valid: true, response: null }; + } -/** - * Middleware to validate CSRF token for state-mutating requests - * - * @param request - The NextRequest to validate - * @param options - Configuration options - * @param options.skipMethods - HTTP methods to skip validation for (default: GET, HEAD, OPTIONS) - * @param options.exemptPaths - Paths to exempt from CSRF validation - * @returns A NextResponse if validation fails, or null if validation passes - */ -export function validateCsrfRequest( - request: NextRequest, - options: { - skipMethods?: string[]; - exemptPaths?: string[]; - } = {} -): NextResponse | null { - const { - skipMethods = ['GET', 'HEAD', 'OPTIONS'], - exemptPaths = [ - '/api/auth/login', - '/api/auth/signup', - '/api/auth/discord', - '/api/auth/email-verification', - ], - } = options; + // Skip CSRF validation for auth endpoints + if (isAuthEndpoint(pathname)) { + return { valid: true, response: null }; + } - const method = request.method; - const path = request.nextUrl.pathname; + // Get token from cookie and header + const cookieToken = getCSRFTokenFromCookie(request); + const headerToken = request.headers.get(CSRF_CONFIG.headerName); - // Skip validation for safe methods - if (skipMethods.includes(method)) { - return null; + // Check for token presence + if (!cookieToken || !headerToken) { + return { + valid: false, + response: NextResponse.json( + { + error: 'CSRF token missing', + message: 'XSRF-TOKEN cookie and x-csrf-token header are required for state-mutating requests' + }, + { status: 403 } + ), + }; } - // Skip validation for exempt paths (e.g., auth endpoints) - if (exemptPaths.some((exemptPath) => path.startsWith(exemptPath))) { - return null; + // Validate token using timing-safe comparison + if (!timingSafeCompare(cookieToken, headerToken)) { + return { + valid: false, + response: NextResponse.json( + { + error: 'Invalid CSRF token', + message: 'The provided CSRF token is invalid or has been tampered with' + }, + { status: 403 } + ), + }; } - // Validate CSRF token - if (!validateCsrfToken(request)) { - return NextResponse.json( - { - success: false, - message: 'CSRF token validation failed. Please refresh the page and try again.', - code: 'CSRF_TOKEN_INVALID' - }, - { status: 403 } - ); - } + // Token is valid + return { valid: true, response: null }; +} - return null; +// Set CSRF token cookie for new sessions +export function setCSRFTokenCookie(response: NextResponse): void { + const token = generateCSRFToken(); + response.cookies.set(CSRF_CONFIG.cookieName, token, CSRF_CONFIG.cookieOptions); } -/** - * Create a new CSRF token and set it as a cookie - * Used for initial token generation on page load - */ -export function createCsrfTokenResponse( - request: NextRequest, - options: { secure?: boolean } = {} -): NextResponse { - const token = generateCsrfToken(); - const response = NextResponse.json({ - csrfToken: token, - message: 'CSRF token generated successfully' - }); - return setCsrfCookie(response, token, options.secure); +// Generate token for client-side usage +export function getCSRFToken(): string { + return generateCSRFToken(); } \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index e52eeec0..2a8cbbc4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,3 +1,4 @@ +import { csrfMiddleware } from './lib/csrfMiddleware'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { checkRoutePermission } from './middleware/rbac'; @@ -29,6 +30,11 @@ export async function middleware(request: NextRequest) { return redirectResponse; } + const csrfResult = csrfMiddleware(request); +if (!csrfResult.valid && csrfResult.response) { + return withHeaders(csrfResult.response); +} + // Verify JWT from Authorization header or cookie — never trust client-supplied role cookies const token = request.headers.get('Authorization')?.replace('Bearer ', '') ??