76 lines
3.1 KiB
TypeScript
76 lines
3.1 KiB
TypeScript
import { Request, Response, NextFunction } from 'express';
|
||
import { timingSafeEqual } from 'crypto';
|
||
import { verifyCsrfToken, isCsrfConfigured } from '../services/csrf.service';
|
||
import { env } from '../config/env';
|
||
import { logger } from '../lib/logger';
|
||
|
||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||
|
||
/**
|
||
* CSRF middleware с double-submit pattern.
|
||
*
|
||
* Требует ОБА source'а: cookie `csrf_token` AND header `X-CSRF-Token`,
|
||
* сравнивает их constant-time. Без обоих или при несовпадении — 403.
|
||
*
|
||
* Защита: если attacker украл только cookie (auto-sent при cross-site POST),
|
||
* он не может выставить header X-CSRF-Token из чужого origin без CORS,
|
||
* а CORS у нас явный whitelist. Single-source check был bypass'able.
|
||
*/
|
||
export function csrfMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||
if (SAFE_METHODS.has(req.method)) {
|
||
next();
|
||
return;
|
||
}
|
||
|
||
// CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем явно.
|
||
if (!env.vault.csrfPath) {
|
||
next();
|
||
return;
|
||
}
|
||
|
||
// Bearer-auth (Authorization header, no access_token cookie) — CSRF не нужен.
|
||
// Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit
|
||
// Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует).
|
||
// Это позволяет API-клиентам (Swagger UI, Postman, curl, мобилке) работать без CSRF.
|
||
if (!req.cookies?.access_token && req.headers.authorization) {
|
||
next();
|
||
return;
|
||
}
|
||
|
||
// CSRF включён, но секрет не загружен → fail-secure 503.
|
||
if (!isCsrfConfigured()) {
|
||
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
||
res.status(503).json({ success: false, error: 'CSRF protection unavailable, retry later' });
|
||
return;
|
||
}
|
||
|
||
const cookieToken = req.cookies?.csrf_token;
|
||
const headerToken = req.headers['x-csrf-token'];
|
||
|
||
// Double-submit: ОБА обязательны.
|
||
if (!cookieToken || typeof cookieToken !== 'string' ||
|
||
!headerToken || typeof headerToken !== 'string') {
|
||
res.status(403).json({ success: false, error: 'CSRF token missing (need cookie + header)' });
|
||
return;
|
||
}
|
||
|
||
// Constant-time сравнение cookie === header (защита от timing oracle).
|
||
const a = Buffer.from(cookieToken);
|
||
const b = Buffer.from(headerToken);
|
||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||
logger.warn('CSRF: cookie/header mismatch');
|
||
res.status(403).json({ success: false, error: 'CSRF token mismatch' });
|
||
return;
|
||
}
|
||
|
||
// HMAC verify только после совпадения двух source'ов.
|
||
const result = verifyCsrfToken(cookieToken);
|
||
if (!result.valid) {
|
||
logger.warn(`CSRF validation failed: ${result.reason}`);
|
||
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
|
||
return;
|
||
}
|
||
|
||
next();
|
||
}
|