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 ecf10eaa..20843283 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 { @@ -65,6 +66,19 @@ export async function POST(request: Request): Promise { + 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 diff --git a/src/lib/csrfMiddleware.ts b/src/lib/csrfMiddleware.ts new file mode 100644 index 00000000..5c6086ae --- /dev/null +++ b/src/lib/csrfMiddleware.ts @@ -0,0 +1,158 @@ +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'; + +/** + * Generate a cryptographically secure CSRF token + */ +export function generateCsrfToken(): string { + return crypto.randomBytes(32).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 the CSRF token from the request headers + */ +export function getCsrfTokenFromHeaders(request: NextRequest): string | undefined { + return request.headers.get(CSRF_HEADER_NAME) || undefined; +} + +/** + * 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); + + // Both tokens must exist + if (!cookieToken || !headerToken) { + return false; + } + + // Compare tokens using timing-safe comparison to prevent timing attacks + return crypto.timingSafeEqual( + Buffer.from(cookieToken, 'hex'), + Buffer.from(headerToken, 'hex') + ); +} + +/** + * 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 + }); + + return response; +} + +/** + * 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; + + const method = request.method; + const path = request.nextUrl.pathname; + + // Skip validation for safe methods + if (skipMethods.includes(method)) { + return null; + } + + // Skip validation for exempt paths (e.g., auth endpoints) + if (exemptPaths.some((exemptPath) => 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 ba9c24bb..d36e403f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -13,6 +13,7 @@ import { VERSIONED_API_ROOT, INTERNAL_API_REQUEST_HEADER, } from './lib/apiVersioning'; +import { validateCsrfRequest, setCsrfCookie, generateCsrfToken, getCsrfTokenFromCookies } from './lib/csrfMiddleware'; export async function middleware(request: NextRequest) { const traceId = crypto.randomUUID(); @@ -43,10 +44,48 @@ export async function middleware(request: NextRequest) { return applyCspHeaders(withSecurity, request, cspNonce); }; + // 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/')) { + 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 + 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(); @@ -91,4 +130,4 @@ export const config = { '/profile/:path*', '/api/:path*', ], -}; +}; \ No newline at end of file