From b0d28ecb13c5e959c7c9d8037c587238ddd73fb3 Mon Sep 17 00:00:00 2001 From: smartalee Date: Tue, 30 Jun 2026 08:22:33 -0400 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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 }));