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..ea6ed9b1 --- /dev/null +++ b/src/lib/csrfMiddleware.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; + +// 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(CSRF_CONFIG.tokenLength).toString('hex'); +} + +// Get CSRF token from cookie +export function getCSRFTokenFromCookie(request: NextRequest): string | undefined { + return request.cookies.get(CSRF_CONFIG.cookieName)?.value; +} + +// Check if request is idempotent (safe method) +function isIdempotentMethod(method: string): boolean { + return ['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()); +} + +// 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)); +} + +// 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); + } + return result === 0; +} + +// Main CSRF middleware handler +export function csrfMiddleware(request: NextRequest) { + const method = request.method; + const pathname = request.nextUrl.pathname; + + // Skip CSRF validation for idempotent methods + if (isIdempotentMethod(method)) { + return { valid: true, response: null }; + } + + // Skip CSRF validation for auth endpoints + if (isAuthEndpoint(pathname)) { + return { valid: true, response: null }; + } + + // Get token from cookie and header + const cookieToken = getCSRFTokenFromCookie(request); + const headerToken = request.headers.get(CSRF_CONFIG.headerName); + + // 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 } + ), + }; + } + + // 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 } + ), + }; + } + + // Token is valid + return { valid: true, response: 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); +} + +// 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 ba9c24bb..d94d723c 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'; @@ -13,6 +14,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(); @@ -28,6 +30,11 @@ export async function middleware(request: NextRequest) { return redirectResponse; } + const csrfResult = csrfMiddleware(request); +if (!csrfResult.valid && csrfResult.response) { + return withHeaders(csrfResult.response); +} + // 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 const token = @@ -43,10 +50,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 +136,4 @@ export const config = { '/profile/:path*', '/api/:path*', ], -}; +}; \ No newline at end of file