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:
@@ -30,6 +30,7 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"firebase-admin": "^13.7.0",
|
"firebase-admin": "^13.7.0",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.0",
|
"@nestjs/testing": "^11.0.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
@@ -58,6 +59,9 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Cookie Parser (required for CSRF double-submit pattern) ──
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
// ── CORS ──
|
// ── CORS ──
|
||||||
const allowedOrigins = (process.env['CORS_ORIGINS'] ?? 'http://localhost:3000')
|
const allowedOrigins = (process.env['CORS_ORIGINS'] ?? 'http://localhost:3000')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -65,7 +69,7 @@ async function bootstrap() {
|
|||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: allowedOrigins,
|
origin: allowedOrigins,
|
||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
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'],
|
exposedHeaders: ['X-Correlation-Id'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
maxAge: 86400,
|
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: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,13 +14,32 @@ type RequestOptions = Omit<RequestInit, 'body'> & {
|
|||||||
body?: unknown;
|
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<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const { body, headers, ...rest } = options;
|
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}`, {
|
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
...rest,
|
...rest,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...csrfHeaders,
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -111,6 +111,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
|
cookie-parser:
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7
|
||||||
firebase-admin:
|
firebase-admin:
|
||||||
specifier: ^13.7.0
|
specifier: ^13.7.0
|
||||||
version: 13.7.0
|
version: 13.7.0
|
||||||
@@ -172,6 +175,9 @@ importers:
|
|||||||
'@types/bcrypt':
|
'@types/bcrypt':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 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':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
@@ -1686,6 +1692,11 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
|
'@types/cookie-parser@1.4.10':
|
||||||
|
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/express': '*'
|
||||||
|
|
||||||
'@types/cookiejar@2.1.5':
|
'@types/cookiejar@2.1.5':
|
||||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||||
|
|
||||||
@@ -2423,6 +2434,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
cookie-signature@1.2.2:
|
||||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||||
engines: {node: '>=6.6.0'}
|
engines: {node: '>=6.6.0'}
|
||||||
@@ -6433,6 +6451,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.17
|
'@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/cookiejar@2.1.5': {}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
@@ -7217,6 +7239,13 @@ snapshots:
|
|||||||
|
|
||||||
content-type@1.0.5: {}
|
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-signature@1.2.2: {}
|
||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user