From e5f370ced1f79fcd147bf8b774707a86767a735b Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 05:03:24 +0700 Subject: [PATCH] feat(security): add CSRF double-submit cookie protection Add CSRF middleware with double-submit cookie pattern for all state-changing requests. Integrate cookie-parser, update CORS headers, and add client-side CSRF token handling. Co-Authored-By: Paperclip --- apps/api/package.json | 2 + apps/api/src/main.ts | 6 ++- .../middleware/csrf.middleware.ts | 48 +++++++++++++++++++ apps/web/lib/api-client.ts | 19 ++++++++ pnpm-lock.yaml | 29 +++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts diff --git a/apps/api/package.json b/apps/api/package.json index bd2eb84..4836d94 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,6 +30,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "cookie-parser": "^1.4.7", "firebase-admin": "^13.7.0", "handlebars": "^4.7.9", "helmet": "^8.1.0", @@ -52,6 +53,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.0", "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.0", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a6ca43d..0accb40 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { LoggerService } from '@modules/shared'; +import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { AppModule } from './app.module'; @@ -58,6 +59,9 @@ async function bootstrap() { }), ); + // ── Cookie Parser (required for CSRF double-submit pattern) ── + app.use(cookieParser()); + // ── CORS ── const allowedOrigins = (process.env['CORS_ORIGINS'] ?? 'http://localhost:3000') .split(',') @@ -65,7 +69,7 @@ async function bootstrap() { app.enableCors({ origin: allowedOrigins, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Correlation-Id'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Correlation-Id', 'X-CSRF-Token'], exposedHeaders: ['X-Correlation-Id'], credentials: true, maxAge: 86400, diff --git a/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts new file mode 100644 index 0000000..f449244 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts @@ -0,0 +1,48 @@ +import { ForbiddenException, Injectable, type NestMiddleware } from '@nestjs/common'; +import { randomBytes } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; + +const CSRF_COOKIE = 'XSRF-TOKEN'; +const CSRF_HEADER = 'x-csrf-token'; +const TOKEN_LENGTH = 32; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +@Injectable() +export class CsrfMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction): void { + // Safe methods: ensure a CSRF cookie exists for the client to read + if (SAFE_METHODS.has(req.method)) { + this.ensureCsrfCookie(req, res); + return next(); + } + + // State-changing methods: validate the double-submit token + const cookieToken = req.cookies?.[CSRF_COOKIE] as string | undefined; + const headerToken = req.headers[CSRF_HEADER] as string | undefined; + + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + throw new ForbiddenException('CSRF token missing or invalid'); + } + + // Rotate token after successful validation + this.setCsrfCookie(res); + next(); + } + + private ensureCsrfCookie(req: Request, res: Response): void { + if (!req.cookies?.[CSRF_COOKIE]) { + this.setCsrfCookie(res); + } + } + + private setCsrfCookie(res: Response): void { + const token = randomBytes(TOKEN_LENGTH).toString('hex'); + res.cookie(CSRF_COOKIE, token, { + httpOnly: false, // Frontend must read this cookie + secure: process.env['NODE_ENV'] === 'production', + sameSite: 'strict', + path: '/', + }); + } +} diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index fa31274..9e51841 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -14,13 +14,32 @@ type RequestOptions = Omit & { body?: unknown; }; +function getCsrfToken(): string | undefined { + if (typeof document === 'undefined') return undefined; + const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/); + return match ? decodeURIComponent(match[1]) : undefined; +} + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + async function request(endpoint: string, options: RequestOptions = {}): Promise { const { body, headers, ...rest } = options; + const method = options.method?.toUpperCase() ?? 'GET'; + + const csrfHeaders: HeadersInit = {}; + if (!SAFE_METHODS.has(method)) { + const csrfToken = getCsrfToken(); + if (csrfToken) { + csrfHeaders['X-CSRF-Token'] = csrfToken; + } + } const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...rest, + credentials: 'include', headers: { 'Content-Type': 'application/json', + ...csrfHeaders, ...headers, }, body: body ? JSON.stringify(body) : undefined, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d03cc7a..f2e5b57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 firebase-admin: specifier: ^13.7.0 version: 13.7.0 @@ -172,6 +175,9 @@ importers: '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.6) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -1686,6 +1692,11 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -2423,6 +2434,13 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -6433,6 +6451,10 @@ snapshots: dependencies: '@types/node': 22.19.17 + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + '@types/cookiejar@2.1.5': {} '@types/deep-eql@4.0.2': {} @@ -7217,6 +7239,13 @@ snapshots: content-type@1.0.5: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {}