security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)

This commit is contained in:
ZOMBIIIIIII
2026-05-12 01:47:58 +03:00
parent c8bc40af97
commit 8dc0855827
37 changed files with 1852 additions and 318 deletions

View File

@@ -35,7 +35,9 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
req.auth = await verifyAccessToken(token);
next();
} catch (err: any) {
// Лог детали server-side, клиенту — единое generic сообщение.
// Иначе err.message distinguishes "expired" vs "bad signature" vs "kid unknown" → info oracle.
logger.warn(`Auth failed: ${err.message}`);
res.status(err.status || 401).json({ success: false, error: err.message || 'Invalid token' });
res.status(401).json({ success: false, error: 'Unauthorized' });
}
}

View File

@@ -1,39 +1,61 @@
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 пустой) — пропускаем.
// Это явная конфигурация, не fail-open.
// CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем явно.
if (!env.vault.csrfPath) {
next();
return;
}
// CSRF включён, но секрет не загружен (Vault недоступен) → fail-secure: 503.
// НИКОГДА не пропускаем mutating запросы при не-валидном состоянии.
// 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 token = req.cookies?.csrf_token || req.headers['x-csrf-token'];
const cookieToken = req.cookies?.csrf_token;
const headerToken = req.headers['x-csrf-token'];
if (!token || typeof token !== 'string') {
res.status(403).json({ success: false, error: 'CSRF token missing' });
// 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;
}
const result = verifyCsrfToken(token);
// 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' });

View File

@@ -31,7 +31,7 @@ export const mutateLimiter = rateLimit({
message: { success: false, error: 'Too many mutating requests' },
});
// Самый строгий — для send / wallet create (anti-abuse / spam tx prevention)
// Самый строгий — для send / wallet create
export const sensitiveLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 10,
@@ -40,3 +40,13 @@ export const sensitiveLimiter = rateLimit({
keyGenerator: keyByUserOrIp,
message: { success: false, error: 'Too many sensitive requests' },
});
// Экстремально строгий — reveal seed phrase, 5/час
export const mnemonicRevealLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: keyByUserOrIp,
message: { success: false, error: 'Too many mnemonic reveal requests' },
});

View File

@@ -2,10 +2,15 @@ import { Request, Response, NextFunction } from 'express';
import { generateUlid } from '../utils/ulid';
import { traceStore } from '../lib/trace-store';
const TRACE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
const traceId = req.headers['x-trace-id'] as string
|| req.headers['x-request-id'] as string
|| generateUlid();
const supplied = (req.headers['x-trace-id'] || req.headers['x-request-id']) as string | undefined;
// Validate client-supplied trace-ID — иначе log injection / trace forgery
const traceId = (typeof supplied === 'string' && TRACE_ID_RE.test(supplied))
? supplied
: generateUlid();
res.setHeader('X-Trace-ID', traceId);