From 77a0f3d107f5715f90362508c501bb128c13b7c8 Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Fri, 29 May 2026 00:45:33 +0300 Subject: [PATCH] initluyhguliohw3eufuer --- apps/api/src/services/csrf.service.ts | 131 +++++++++++++++++++------- 1 file changed, 96 insertions(+), 35 deletions(-) diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index 94fd08c..cc5140c 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -6,15 +6,22 @@ import { logger } from '../lib/logger'; /** * CSRF validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF). * Token: .. + * + * Vault: read-only. `secret_key` обязателен; `salt`/`digest` опциональны (дефолты при отсутствии). */ const ITSDANGEROUS_EPOCH = 1293840000; +const DEFAULT_SALT = 'itsdangerous.Signer'; +const LEGACY_VERIFY_SALTS = ['csrf-salt', 'csrf', 'csrf-token', 'itsdangerous.Signer'] as const; + export interface CsrfConfig { secret: string; salt: string; digest: 'sha256' | 'sha512'; maxAgeSec: number; + saltFromVault: boolean; + digestFromVault: boolean; } let current: CsrfConfig | null = null; @@ -27,7 +34,6 @@ export function isCsrfConfigured(): boolean { return current !== null; } -/** Первые 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); } @@ -39,6 +45,8 @@ export interface CsrfConfigSummary { digest: 'sha256' | 'sha512'; maxAgeSec: number; secretFp: string; + saltSource: 'vault' | 'default'; + digestSource: 'vault' | 'default'; } export function getCsrfConfigSummary(): CsrfConfigSummary | null { @@ -50,6 +58,8 @@ export function getCsrfConfigSummary(): CsrfConfigSummary | null { digest: current.digest, maxAgeSec: current.maxAgeSec, secretFp: csrfSecretFingerprint(current.secret), + saltSource: current.saltFromVault ? 'vault' : 'default', + digestSource: current.digestFromVault ? 'vault' : 'default', }; } @@ -59,8 +69,14 @@ export function logCsrfConfigLoaded(): void { logger.info( `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}`, ); + if (summary.saltSource === 'default') { + logger.warn( + 'CSRF salt missing in Vault KV — using default itsdangerous.Signer (read-only; verify uses legacy salt matrix)', + ); + } } export async function fetchCsrfConfig( @@ -80,19 +96,18 @@ export async function fetchCsrfConfig( throw new Error('CSRF secret invalid: must be string >= 32 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 saltFromVault = false; + let salt = DEFAULT_SALT; + if (secrets.salt && typeof secrets.salt === 'string' && secrets.salt.length >= 1) { + salt = secrets.salt; + saltFromVault = true; } + let digestFromVault = false; 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)`); + digestFromVault = true; } let maxAgeSec = 60 * 60 * 24 * 7; @@ -101,7 +116,7 @@ export async function fetchCsrfConfig( if (!Number.isNaN(n) && n > 0) maxAgeSec = n; } - return { secret, salt, digest, maxAgeSec }; + return { secret, salt, digest, maxAgeSec, saltFromVault, digestFromVault }; } function b64urlDecode(s: string): Buffer { @@ -130,6 +145,16 @@ export interface CsrfVerifyResult { type CsrfDigest = 'sha1' | 'sha256' | 'sha512'; +const DIGEST_BY_SIG_LEN: Record = { + 20: 'sha1', + 32: 'sha256', + 64: 'sha512', +}; + +const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512']; + +const MIN_VERIFY_SALT_LEN = 1; + function verifyCsrfTokenWithParams( cfg: CsrfConfig, salt: string, @@ -183,41 +208,77 @@ function verifyCsrfTokenWithParams( return { valid: true }; } -function allowSha1VerifyBridge(): boolean { - return process.env.CSRF_ALLOW_SHA1_VERIFY === 'true'; +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'; } /** - * Strict verify: Vault salt + digest only. - * Optional bridge: CSRF_ALLOW_SHA1_VERIFY=true tries sha1 + same Vault salt (legacy auth, one release). + * Verify: Vault salt+digest first, then legacy matrix (same secret_key from Vault, read-only). */ export function verifyCsrfToken(token: string): CsrfVerifyResult { if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; - const primary = verifyCsrfTokenWithParams(current, current.salt, current.digest, token); + const vaultSalt = current.salt; + const vaultDigest = current.digest as CsrfDigest; + const salts = saltsToTry(vaultSalt); + + const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token); if (primary.valid) return primary; - 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)' }; + 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.', + ); + return { valid: true }; + } + if (attempt.reason === 'Signature mismatch' || attempt.reason === 'Signature length mismatch') { + lastMismatch = attempt; + } } } - if (primary.reason === 'Signature mismatch') { - return { valid: false, reason: 'Signature mismatch (Vault secret/salt does not match token)' }; - } - - return primary; + return { + valid: false, + reason: 'Signature mismatch (all digest/salt fallbacks failed)', + actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen, + expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen, + }; }