Files
cryptowallet/apps/api/src/middleware/csrf.ts
2026-05-13 00:17:32 +03:00

76 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}