security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user