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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user