From 860a22eb4ad4217bf24739ddb168715418c8ad63 Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Fri, 29 May 2026 01:03:03 +0300 Subject: [PATCH] initluyhgulednj --- apps/api/src/services/csrf.service.ts | 154 ++++++++++++++++++++------ 1 file changed, 123 insertions(+), 31 deletions(-) diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index cc5140c..bd4ae13 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -8,12 +8,33 @@ import { logger } from '../lib/logger'; * Token: .. * * Vault: read-only. `secret_key` обязателен; `salt`/`digest` опциональны (дефолты при отсутствии). + * + * itsdangerous 2.x по умолчанию: key_derivation=django-concat, digest=sha1. + * Старый Node-код использовал legacy-hmac-signer — из-за этого prod 403 при верном secret_key. */ const ITSDANGEROUS_EPOCH = 1293840000; const DEFAULT_SALT = 'itsdangerous.Signer'; -const LEGACY_VERIFY_SALTS = ['csrf-salt', 'csrf', 'csrf-token', 'itsdangerous.Signer'] as const; +const LEGACY_VERIFY_SALTS = [ + 'csrf-salt', + 'csrf', + 'csrf-token', + 'wtf', + 'wtf-csrf', + 'itsdangerous.Signer', +] as const; + +/** Порядок: сначала то, что реально ставит itsdangerous 2.x / Flask-WTF. */ +const KEY_DERIVATIONS = [ + 'django-concat', + 'legacy-hmac-signer', + 'hmac', + 'concat', + 'none', +] as const; + +export type CsrfKeyDerivation = (typeof KEY_DERIVATIONS)[number]; export interface CsrfConfig { secret: string; @@ -70,7 +91,7 @@ export function logCsrfConfigLoaded(): void { `CSRF config loaded: mount=${summary.mount} path=${summary.path} ` + `salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` + `salt_source=${summary.saltSource} digest_source=${summary.digestSource} ` + - `secret_fp=${summary.secretFp}`, + `secret_fp=${summary.secretFp} verify_key_derivations=${KEY_DERIVATIONS.join(',')}`, ); if (summary.saltSource === 'default') { logger.warn( @@ -125,10 +146,54 @@ function b64urlDecode(s: string): Buffer { return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); } -function deriveKey(secret: string, salt: string, digest: string): Buffer { +/** itsdangerous 2.x default: hash(salt + b"signer" + secret_key). */ +function deriveKeyDjangoConcat(secret: string, salt: string, digest: string): Buffer { + const data = Buffer.concat([ + Buffer.from(salt, 'utf8'), + Buffer.from('signer', 'utf8'), + Buffer.from(secret, 'utf8'), + ]); + return crypto.createHash(digest).update(data).digest(); +} + +/** itsdangerous key_derivation="hmac": HMAC(secret, salt). */ +function deriveKeyHmac(secret: string, salt: string, digest: string): Buffer { + return crypto.createHmac(digest, secret).update(salt, 'utf8').digest(); +} + +/** itsdangerous key_derivation="concat": hash(salt + secret). */ +function deriveKeyConcat(secret: string, salt: string, digest: string): Buffer { + const data = Buffer.concat([Buffer.from(salt, 'utf8'), Buffer.from(secret, 'utf8')]); + return crypto.createHash(digest).update(data).digest(); +} + +/** Старый wallet / часть 1.x: HMAC(secret, salt + "signer"). */ +function deriveKeyLegacyHmacSigner(secret: string, salt: string, digest: string): Buffer { return crypto.createHmac(digest, secret).update(salt + 'signer').digest(); } +export function deriveSigningKey( + secret: string, + salt: string, + digest: string, + mode: CsrfKeyDerivation, +): Buffer { + switch (mode) { + case 'django-concat': + return deriveKeyDjangoConcat(secret, salt, digest); + case 'hmac': + return deriveKeyHmac(secret, salt, digest); + case 'concat': + return deriveKeyConcat(secret, salt, digest); + case 'legacy-hmac-signer': + return deriveKeyLegacyHmacSigner(secret, salt, digest); + case 'none': + return Buffer.from(secret, 'utf8'); + default: + return deriveKeyDjangoConcat(secret, salt, digest); + } +} + function decodeTimestamp(encoded: string): number { const raw = b64urlDecode(encoded); let ts = 0; @@ -159,6 +224,7 @@ function verifyCsrfTokenWithParams( cfg: CsrfConfig, salt: string, digest: CsrfDigest, + keyDerivation: CsrfKeyDerivation, token: string, ): CsrfVerifyResult { if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' }; @@ -174,7 +240,7 @@ function verifyCsrfTokenWithParams( const tsStr = payloadTs.slice(prevDot + 1); - const derived = deriveKey(cfg.secret, salt, digest); + const derived = deriveSigningKey(cfg.secret, salt, digest, keyDerivation); const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest(); let actualSig: Buffer; @@ -232,53 +298,79 @@ function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfD return order; } +function derivationsToTry(primary: CsrfVerifyResult): CsrfKeyDerivation[] { + const order: CsrfKeyDerivation[] = [...KEY_DERIVATIONS]; + if (primary.actualSigLen === 20) { + // Prod auth: itsdangerous 2.x + sha1 → django-concat первым. + return ['django-concat', ...order.filter((d) => d !== 'django-concat')]; + } + return order; +} + function isRetryableVerifyFailure(reason?: string): boolean { return reason === 'Signature length mismatch' || reason === 'Signature mismatch'; } /** - * Verify: Vault salt+digest first, then legacy matrix (same secret_key from Vault, read-only). + * Verify: django-concat (itsdangerous 2.x) + legacy matrix (salt × digest × key_derivation). */ +function inferSigLenFromToken(token: string): number | undefined { + const lastDot = token.lastIndexOf('.'); + if (lastDot < 0) return undefined; + try { + return b64urlDecode(token.slice(lastDot + 1)).length; + } catch { + return undefined; + } +} + 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 sigProbe: CsrfVerifyResult = { valid: false, actualSigLen: inferSigLenFromToken(token) }; + const derivations = derivationsToTry(sigProbe); - const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token); - if (primary.valid) return primary; + let lastMismatch: CsrfVerifyResult = { valid: false, reason: 'Signature mismatch' }; - 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}" ` + - `(config digest=${vaultDigest} salt="${vaultSalt}"). ` + - 'Align auth signing with Vault read config.', + for (const keyDerivation of derivations) { + for (const salt of salts) { + for (const digest of digestsToTry(sigProbe.actualSigLen !== undefined ? sigProbe : lastMismatch, vaultDigest)) { + const attempt = verifyCsrfTokenWithParams( + current, + salt, + digest, + keyDerivation, + token, ); - return { valid: true }; - } - if (attempt.reason === 'Signature mismatch' || attempt.reason === 'Signature length mismatch') { - lastMismatch = attempt; + if (attempt.valid) { + const isPrimary = + keyDerivation === 'django-concat' && + salt === vaultSalt && + digest === vaultDigest; + if (!isPrimary) { + logger.warn( + `CSRF verified with fallback key_derivation=${keyDerivation} digest=${digest} salt="${salt}" ` + + `(config digest=${vaultDigest} salt="${vaultSalt}"). Align auth with Vault metadata when possible.`, + ); + } + return { valid: true }; + } + if (isRetryableVerifyFailure(attempt.reason)) { + lastMismatch = attempt; + } else if (attempt.reason && attempt.reason !== 'Signature mismatch') { + return attempt; + } } } } return { valid: false, - reason: 'Signature mismatch (all digest/salt fallbacks failed)', - actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen, - expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen, + reason: 'Signature mismatch (all digest/salt/key_derivation fallbacks failed)', + actualSigLen: lastMismatch.actualSigLen, + expectedSigLen: lastMismatch.expectedSigLen, }; }