diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index 56fee95..c48f26c 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -8,6 +8,8 @@ import { logger } from '../lib/logger'; * * Token format: .. * + * Digests: sha1 (20-byte sig, legacy Flask-WTF), sha256 (32), sha512 (64, itsdangerous 2.x default). + * * Default algorithm (itsdangerous ≥ 2.0): * - digest: SHA-512 (HMAC) * - salt: "itsdangerous.Signer" (or app-specific, e.g. "csrf-token") @@ -155,7 +157,20 @@ export function generateCsrfToken(): { token: string; maxAgeSec: number } { }; } -function verifyCsrfTokenWithConfig(cfg: CsrfConfig, token: string): CsrfVerifyResult { +type CsrfDigest = 'sha1' | 'sha256' | 'sha512'; + +/** HMAC output length → digest (auth-service legacy часто sha1 → sigLen=20). */ +const DIGEST_BY_SIG_LEN: Record = { + 20: 'sha1', + 32: 'sha256', + 64: 'sha512', +}; + +function verifyCsrfTokenWithDigest( + cfg: CsrfConfig, + digest: CsrfDigest, + token: string, +): CsrfVerifyResult { if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' }; const lastDot = token.lastIndexOf('.'); @@ -169,8 +184,8 @@ function verifyCsrfTokenWithConfig(cfg: CsrfConfig, token: string): CsrfVerifyRe const tsStr = payloadTs.slice(prevDot + 1); - const derived = deriveKey(cfg.secret, cfg.salt, cfg.digest); - const expectedSig = crypto.createHmac(cfg.digest, derived).update(payloadTs).digest(); + const derived = deriveKey(cfg.secret, cfg.salt, digest); + const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest(); let actualSig: Buffer; try { @@ -203,28 +218,47 @@ function verifyCsrfTokenWithConfig(cfg: CsrfConfig, token: string): CsrfVerifyRe return { valid: true }; } +function digestsToTry(primary: CsrfVerifyResult): CsrfDigest[] { + const order: CsrfDigest[] = []; + const add = (d: CsrfDigest) => { + if (!order.includes(d)) order.push(d); + }; + + add(current!.digest); + if (primary.actualSigLen !== undefined) { + const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen]; + if (inferred) add(inferred); + } + add('sha1'); + add('sha256'); + add('sha512'); + return order; +} + /** - * Verify CSRF token. If Vault digest differs from auth-service (sha256 vs sha512), - * retry once with the alternate digest — типичный случай Flask-WTF / itsdangerous 2.x. + * Verify CSRF token. Fallback по длине подписи: sha1 (20) / sha256 (32) / sha512 (64). */ export function verifyCsrfToken(token: string): CsrfVerifyResult { if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; - const primary = verifyCsrfTokenWithConfig(current, token); + const primaryDigest = current.digest; + const primary = verifyCsrfTokenWithDigest(current, primaryDigest, token); if (primary.valid) return primary; - if (primary.reason !== 'Signature length mismatch') { + if (primary.reason !== 'Signature length mismatch' && primary.reason !== 'Signature mismatch') { return primary; } - const altDigest: 'sha256' | 'sha512' = current.digest === 'sha256' ? 'sha512' : 'sha256'; - const fallback = verifyCsrfTokenWithConfig({ ...current, digest: altDigest }, token); - if (fallback.valid) { - logger.warn( - `CSRF verified with fallback digest ${altDigest} (Vault digest=${current.digest}). ` + - 'Align auth-service URLSafeTimedSerializer digest_method with Vault `digest` field.', - ); - return { valid: true }; + for (const digest of digestsToTry(primary)) { + if (digest === primaryDigest) continue; + const attempt = verifyCsrfTokenWithDigest(current, digest, token); + if (attempt.valid) { + logger.warn( + `CSRF verified with fallback digest ${digest} (Vault digest=${primaryDigest}, ` + + `sigLen=${primary.actualSigLen ?? '?'}). Align auth digest_method with Vault.`, + ); + return { valid: true }; + } } return primary;