diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3ab41a1..ac77eb2 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,7 +10,6 @@ import { authMiddleware } from './middleware/auth'; import { csrfMiddleware } from './middleware/csrf'; import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit'; import { errorHandler } from './middleware/error-handler'; -import { generateCsrfToken } from './services/csrf.service'; import walletRoutes from './routes/wallet.routes'; import relayProxyRoutes from './routes/relay-proxy.routes'; import jumperProxyRoutes from './routes/jumper-proxy.routes'; @@ -67,30 +66,6 @@ app.get('/api/health', async (_req, res) => { // ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json app.use('/api', globalLimiter); -app.get('/api/csrf', (_req, res) => { - if (!env.vault.csrfPath) { - res.json({ success: true, data: { enabled: false, csrfToken: null } }); - return; - } - - try { - const { token, maxAgeSec } = generateCsrfToken(); - const crossSiteCookie = !corsIsWildcard && env.cors.allowCredentials; - res.cookie('csrf_token', token, { - httpOnly: false, - secure: crossSiteCookie, - sameSite: crossSiteCookie ? 'none' : 'lax', - maxAge: maxAgeSec * 1000, - path: '/', - }); - res.setHeader('Cache-Control', 'no-store'); - res.setHeader('X-CSRF-Token', token); - res.json({ success: true, data: { enabled: true, csrfToken: token, maxAgeSec } }); - } catch (err: any) { - res.status(503).json({ success: false, error: err.message || 'CSRF token unavailable' }); - } -}); - // H1 — Swagger gated. В production требуется basic-auth ИЛИ NODE_ENV != production. // JSON endpoint ОБЯЗАТЕЛЬНО до app.use('/api/docs', ...) — иначе swagger-ui-express // перехватывает все /api/docs/* и возвращает HTML вместо JSON. diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index a8a3c0f..9d1eaa9 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { timingSafeEqual } from 'crypto'; -import { verifyCsrfToken, isCsrfConfigured } from '../services/csrf.service'; +import { verifyCsrfToken, isCsrfConfigured, getCsrfConfigSummary } from '../services/csrf.service'; import { env } from '../config/env'; import { logger } from '../lib/logger'; @@ -28,16 +28,6 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction): return; } - // Bearer-auth (explicit Authorization header) — CSRF не нужен. - // Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit - // Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует). - // Это позволяет API-клиентам и фронту с Bearer-token работать без double-submit CSRF, - // даже если браузер параллельно прислал stale access_token cookie. - if (req.headers.authorization) { - next(); - return; - } - // CSRF включён, но секрет не загружен → fail-secure 503. if (!isCsrfConfigured()) { logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request'); @@ -71,7 +61,9 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction): result.actualSigLen !== undefined && result.expectedSigLen !== undefined ? ` (sigLen=${result.actualSigLen} expectedLen=${result.expectedSigLen})` : ''; - logger.warn(`CSRF validation failed: ${result.reason}${sigDiag}`); + const cfg = getCsrfConfigSummary(); + const fp = cfg ? ` secret_fp=${cfg.secretFp} salt="${cfg.salt}" digest=${cfg.digest}` : ''; + logger.warn(`CSRF validation failed: ${result.reason}${sigDiag}${fp}`); res.status(403).json({ success: false, error: 'Invalid CSRF token' }); return; } diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index 5af5487..94fd08c 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -1,20 +1,14 @@ import crypto from 'crypto'; import { fetchVaultKV2 } from '../config/vault'; +import { env } from '../config/env'; import { logger } from '../lib/logger'; /** - * CSRF token validation compatible with Python's `itsdangerous` - * `URLSafeTimedSerializer` (which Flask-WTF uses). - * - * Token format: .. - * - * Digests: sha1 (20-byte sig, legacy Flask-WTF), sha256 (32), sha512 (64, itsdangerous 2.x default). + * CSRF validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF). + * Token: .. */ -const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time - -/** Типичные salt у auth / Flask-WTF — только для verify fallback, не для generate. */ -const LEGACY_VERIFY_SALTS = ['csrf-salt', 'csrf', 'csrf-token', 'itsdangerous.Signer'] as const; +const ITSDANGEROUS_EPOCH = 1293840000; export interface CsrfConfig { secret: string; @@ -33,8 +27,40 @@ export function isCsrfConfigured(): boolean { return current !== null; } -function b64urlEncode(buf: Buffer): string { - return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +/** Первые 8 hex SHA-256(secret) — для сравнения auth vs wallet без утечки ключа. */ +export function csrfSecretFingerprint(secret: string): string { + return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 8); +} + +export interface CsrfConfigSummary { + mount: string; + path: string; + salt: string; + digest: 'sha256' | 'sha512'; + maxAgeSec: number; + secretFp: string; +} + +export function getCsrfConfigSummary(): CsrfConfigSummary | null { + if (!current || !env.vault.csrfPath) return null; + return { + mount: env.vault.mount, + path: env.vault.csrfPath, + salt: current.salt, + digest: current.digest, + maxAgeSec: current.maxAgeSec, + secretFp: csrfSecretFingerprint(current.secret), + }; +} + +export function logCsrfConfigLoaded(): void { + const summary = getCsrfConfigSummary(); + if (!summary) return; + logger.info( + `CSRF config loaded: mount=${summary.mount} path=${summary.path} ` + + `salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` + + `secret_fp=${summary.secretFp}`, + ); } export async function fetchCsrfConfig( @@ -54,19 +80,24 @@ export async function fetchCsrfConfig( throw new Error('CSRF secret invalid: must be string >= 32 chars'); } - const salt = secrets.salt || 'itsdangerous.Signer'; - if (typeof salt !== 'string' || salt.length < 8) { - throw new Error('CSRF salt invalid: must be string >= 8 chars'); + const salt = secrets.salt; + if (!salt || typeof salt !== 'string' || salt.length < 4) { + throw new Error( + 'CSRF salt required in Vault KV (field "salt", min 4 chars). ' + + 'Example: vault kv patch -mount= salt=csrf-salt digest=sha256', + ); } let digest: 'sha256' | 'sha512' = 'sha256'; if (secrets.digest === 'sha256' || secrets.digest === 'sha512') { digest = secrets.digest; + } else if (secrets.digest) { + throw new Error(`CSRF digest invalid: ${secrets.digest} (allowed: sha256, sha512)`); } let maxAgeSec = 60 * 60 * 24 * 7; if (secrets.max_age_sec) { - const n = parseInt(secrets.max_age_sec); + const n = parseInt(String(secrets.max_age_sec), 10); if (!Number.isNaN(n) && n > 0) maxAgeSec = n; } @@ -90,71 +121,15 @@ function decodeTimestamp(encoded: string): number { return ts + ITSDANGEROUS_EPOCH; } -function encodeTimestamp(unixSeconds: number): string { - let ts = unixSeconds - ITSDANGEROUS_EPOCH; - const bytes: number[] = []; - do { - bytes.unshift(ts & 0xff); - ts = Math.floor(ts / 256); - } while (ts > 0); - return b64urlEncode(Buffer.from(bytes)); -} - export interface CsrfVerifyResult { valid: boolean; reason?: string; actualSigLen?: number; expectedSigLen?: number; - fallbackDigest?: CsrfDigest; - fallbackSalt?: string; -} - -export interface CsrfConfigSummary { - salt: string; - digest: 'sha256' | 'sha512'; - maxAgeSec: number; } type CsrfDigest = 'sha1' | 'sha256' | 'sha512'; -const DIGEST_BY_SIG_LEN: Record = { - 20: 'sha1', - 32: 'sha256', - 64: 'sha512', -}; - -const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512']; - -export function getCsrfConfigSummary(): CsrfConfigSummary | null { - if (!current) return null; - return { salt: current.salt, digest: current.digest, maxAgeSec: current.maxAgeSec }; -} - -export function logCsrfConfigLoaded(): void { - const summary = getCsrfConfigSummary(); - if (!summary) return; - logger.info( - `CSRF config loaded: salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec}`, - ); -} - -export function generateCsrfToken(): { token: string; maxAgeSec: number } { - if (!current) { - throw new Error('CSRF secret not loaded'); - } - - const payload = b64urlEncode(crypto.randomBytes(32)); - const timestamp = encodeTimestamp(Math.floor(Date.now() / 1000)); - const payloadTs = `${payload}.${timestamp}`; - const derived = deriveKey(current.secret, current.salt, current.digest); - const signature = b64urlEncode(crypto.createHmac(current.digest, derived).update(payloadTs).digest()); - - return { - token: `${payloadTs}.${signature}`, - maxAgeSec: current.maxAgeSec, - }; -} - function verifyCsrfTokenWithParams( cfg: CsrfConfig, salt: string, @@ -208,80 +183,41 @@ function verifyCsrfTokenWithParams( return { valid: true }; } -/** Минимальная длина salt при verify-fallback (Flask-WTF default `csrf` = 4). */ -const MIN_VERIFY_SALT_LEN = 1; - -function saltsToTry(vaultSalt: string): string[] { - const out: string[] = []; - const add = (s: string, minLen = MIN_VERIFY_SALT_LEN) => { - if (s && s.length >= minLen && !out.includes(s)) out.push(s); - }; - add(vaultSalt, 8); - for (const s of LEGACY_VERIFY_SALTS) add(s); - return out; -} - -function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfDigest[] { - const order: CsrfDigest[] = []; - const add = (d: CsrfDigest) => { - if (!order.includes(d)) order.push(d); - }; - add(vaultDigest); - if (primary.actualSigLen !== undefined) { - const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen]; - if (inferred) add(inferred); - } - for (const d of ALL_DIGESTS) add(d); - return order; -} - -function isRetryableVerifyFailure(reason?: string): boolean { - return reason === 'Signature length mismatch' || reason === 'Signature mismatch'; +function allowSha1VerifyBridge(): boolean { + return process.env.CSRF_ALLOW_SHA1_VERIFY === 'true'; } /** - * Verify CSRF: Vault salt+digest first, then legacy digest/salt matrix (auth Flask-WTF). + * Strict verify: Vault salt + digest only. + * Optional bridge: CSRF_ALLOW_SHA1_VERIFY=true tries sha1 + same Vault salt (legacy auth, one release). */ export function verifyCsrfToken(token: string): CsrfVerifyResult { if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; - const vaultSalt = current.salt; - const vaultDigest = current.digest as CsrfDigest; - const salts = saltsToTry(vaultSalt); - - const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token); + const primary = verifyCsrfTokenWithParams(current, current.salt, current.digest, token); if (primary.valid) return primary; - if (!isRetryableVerifyFailure(primary.reason)) { - return primary; - } - - const digests = digestsToTry(primary, vaultDigest); - let lastMismatch: CsrfVerifyResult = primary; - - for (const salt of salts) { - for (const digest of digests) { - if (salt === vaultSalt && digest === vaultDigest) continue; - - const attempt = verifyCsrfTokenWithParams(current, salt, digest, token); - if (attempt.valid) { - logger.warn( - `CSRF verified with fallback digest=${digest} salt="${salt}" ` + - `(Vault digest=${vaultDigest} salt="${vaultSalt}"). ` + - 'Align auth-service with Vault `digest` and `salt` fields.', - ); - return { valid: true, fallbackDigest: digest, fallbackSalt: salt }; - } - if (attempt.reason === 'Signature mismatch' || attempt.reason === 'Signature length mismatch') { - lastMismatch = attempt; - } + if ( + allowSha1VerifyBridge() && + primary.reason === 'Signature length mismatch' && + primary.actualSigLen === 20 && + (current.digest === 'sha256' || current.digest === 'sha512') + ) { + const legacy = verifyCsrfTokenWithParams(current, current.salt, 'sha1', token); + if (legacy.valid) { + logger.warn( + 'CSRF verified via CSRF_ALLOW_SHA1_VERIFY sha1 bridge — migrate auth to Vault digest=sha256', + ); + return legacy; + } + if (legacy.reason === 'Signature mismatch') { + return { valid: false, reason: 'Signature mismatch (Vault secret/salt does not match token)' }; } } - return { - valid: false, - reason: 'Signature mismatch (all digest/salt fallbacks failed)', - actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen, - expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen, - }; + if (primary.reason === 'Signature mismatch') { + return { valid: false, reason: 'Signature mismatch (Vault secret/salt does not match token)' }; + } + + return primary; }