diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index bd4ae13..c1b0910 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -194,11 +194,61 @@ export function deriveSigningKey( } } -function decodeTimestamp(encoded: string): number { +/** Big-endian int from b64url timestamp chunk (без epoch). */ +function decodeTimestampRaw(encoded: string): number { const raw = b64urlDecode(encoded); let ts = 0; for (const b of raw) ts = ts * 256 + b; - return ts + ITSDANGEROUS_EPOCH; + return ts; +} + +const TIMESTAMP_SKEW_SEC = 60; +/** Ниже — отсекаем legacy raw, чтобы не путать с unix. */ +const MIN_PLAUSIBLE_UNIX = 1_577_836_800; + +type TimestampCheck = 'ok' | 'future' | 'expired'; + +function checkIssuedAt(issuedAt: number, maxAgeSec: number): TimestampCheck { + const now = Math.floor(Date.now() / 1000); + if (issuedAt > now + TIMESTAMP_SKEW_SEC) return 'future'; + if (now - issuedAt > maxAgeSec) return 'expired'; + return 'ok'; +} + +/** + * itsdangerous 2.x (prod auth): unix в payload. + * Старый URLSafeTimedSerializer / test-jwt-signer: raw + ITSDANGEROUS_EPOCH. + */ +function verifyCsrfTimestamp( + tsStr: string, + maxAgeSec: number, +): { ok: true; mode: 'unix' | 'legacy-epoch' } | { ok: false; reason: string } { + let raw: number; + try { + raw = decodeTimestampRaw(tsStr); + } catch { + return { ok: false, reason: 'Invalid timestamp' }; + } + + const candidates: { issuedAt: number; mode: 'unix' | 'legacy-epoch' }[] = [ + { issuedAt: raw, mode: 'unix' }, + { issuedAt: raw + ITSDANGEROUS_EPOCH, mode: 'legacy-epoch' }, + ]; + + let lastReason = 'Invalid timestamp'; + for (const { issuedAt, mode } of candidates) { + if (issuedAt < MIN_PLAUSIBLE_UNIX) continue; + const check = checkIssuedAt(issuedAt, maxAgeSec); + if (check === 'ok') { + if (mode === 'legacy-epoch') { + logger.warn('CSRF timestamp decoded as legacy-epoch (itsdangerous 1.x / test signer)'); + } + return { ok: true, mode }; + } + lastReason = check === 'future' ? 'Token from the future' : 'Token expired'; + } + + return { ok: false, reason: lastReason }; } export interface CsrfVerifyResult { @@ -262,13 +312,9 @@ function verifyCsrfTokenWithParams( return { valid: false, reason: 'Signature mismatch' }; } - try { - const issuedAt = decodeTimestamp(tsStr); - const now = Math.floor(Date.now() / 1000); - if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' }; - if (now - issuedAt > cfg.maxAgeSec) return { valid: false, reason: 'Token expired' }; - } catch { - return { valid: false, reason: 'Invalid timestamp' }; + const tsResult = verifyCsrfTimestamp(tsStr, cfg.maxAgeSec); + if (!tsResult.ok) { + return { valid: false, reason: tsResult.reason }; } return { valid: true };