101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import * as crypto from 'crypto';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { ApiError } from '../errors/api-error';
|
|
import { ERROR_CODES } from '../errors/error-codes';
|
|
|
|
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
const COOKIE_NAME = 'csrf';
|
|
const HEADER_NAME = 'x-csrf-token';
|
|
const SKIP_PATH_PREFIXES = [
|
|
'/api/v1/auth/register-first-admin',
|
|
'/api/v1/setup/create-admin',
|
|
];
|
|
|
|
function isSkipped(path: string): boolean {
|
|
return SKIP_PATH_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
|
|
}
|
|
|
|
function readCookie(req: Request): string | null {
|
|
const header = req.headers.cookie;
|
|
if (!header) return null;
|
|
const parts = header.split(';');
|
|
for (const part of parts) {
|
|
const [k, ...v] = part.trim().split('=');
|
|
if (k === COOKIE_NAME) return v.join('=');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the request's effective path. In NestJS mounted middleware,
|
|
* `req.path` may already be stripped to '/' when the controller is reached
|
|
* through a nested router; use the original URL as the authoritative source.
|
|
*/
|
|
function requestPath(req: Request): string {
|
|
const orig = req.originalUrl || req.url || req.path || '/';
|
|
const qIdx = orig.indexOf('?');
|
|
return qIdx >= 0 ? orig.slice(0, qIdx) : orig;
|
|
}
|
|
|
|
function safeEqual(a: string, b: string): boolean {
|
|
const bufA = Buffer.from(a);
|
|
const bufB = Buffer.from(b);
|
|
if (bufA.length !== bufB.length) return false;
|
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
}
|
|
|
|
@Injectable()
|
|
export class CsrfMiddleware implements NestMiddleware {
|
|
constructor(private readonly config: ConfigService) {}
|
|
|
|
/**
|
|
* Public helper: ensure a `csrf` cookie exists on the response and return its value.
|
|
* If a token already exists in the request cookies, that one is returned and re-set.
|
|
* Otherwise a new one is minted, set on the response, and returned.
|
|
*/
|
|
setOrGetCsrfToken(req: Request, res: Response): string {
|
|
const existing = readCookie(req);
|
|
if (existing) {
|
|
this.writeCookie(res, existing);
|
|
return existing;
|
|
}
|
|
const token = crypto.randomBytes(24).toString('base64url');
|
|
this.writeCookie(res, token);
|
|
return token;
|
|
}
|
|
|
|
use(req: Request, res: Response, next: NextFunction): void {
|
|
const path = requestPath(req);
|
|
if (isSkipped(path)) {
|
|
this.setOrGetCsrfToken(req, res);
|
|
return next();
|
|
}
|
|
|
|
if (!UNSAFE_METHODS.has(req.method)) {
|
|
this.setOrGetCsrfToken(req, res);
|
|
return next();
|
|
}
|
|
|
|
this.setOrGetCsrfToken(req, res);
|
|
|
|
const cookieToken = readCookie(req);
|
|
const headerToken = (req.headers[HEADER_NAME] as string | undefined) ?? '';
|
|
if (!cookieToken || !headerToken || !safeEqual(cookieToken, headerToken)) {
|
|
throw new ApiError(ERROR_CODES.CSRF_INVALID, 'CSRF token missing or invalid', 403);
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
private writeCookie(res: Response, token: string): void {
|
|
const tls = this.config.get<boolean>('TLS_ENABLED', false);
|
|
res.cookie(COOKIE_NAME, token, {
|
|
httpOnly: false,
|
|
sameSite: tls ? 'strict' : 'lax',
|
|
secure: tls,
|
|
path: '/',
|
|
});
|
|
}
|
|
} |