initluyhguliohw3eufuer

This commit is contained in:
ZOMBIIIIIII
2026-05-29 00:45:33 +03:00
parent b3f61353b3
commit 77a0f3d107

View File

@@ -6,15 +6,22 @@ import { logger } from '../lib/logger';
/** /**
* CSRF validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF). * CSRF validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF).
* Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature> * Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
*
* Vault: read-only. `secret_key` обязателен; `salt`/`digest` опциональны (дефолты при отсутствии).
*/ */
const ITSDANGEROUS_EPOCH = 1293840000; 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 { export interface CsrfConfig {
secret: string; secret: string;
salt: string; salt: string;
digest: 'sha256' | 'sha512'; digest: 'sha256' | 'sha512';
maxAgeSec: number; maxAgeSec: number;
saltFromVault: boolean;
digestFromVault: boolean;
} }
let current: CsrfConfig | null = null; let current: CsrfConfig | null = null;
@@ -27,7 +34,6 @@ export function isCsrfConfigured(): boolean {
return current !== null; return current !== null;
} }
/** Первые 8 hex SHA-256(secret) — для сравнения auth vs wallet без утечки ключа. */
export function csrfSecretFingerprint(secret: string): string { export function csrfSecretFingerprint(secret: string): string {
return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 8); return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 8);
} }
@@ -39,6 +45,8 @@ export interface CsrfConfigSummary {
digest: 'sha256' | 'sha512'; digest: 'sha256' | 'sha512';
maxAgeSec: number; maxAgeSec: number;
secretFp: string; secretFp: string;
saltSource: 'vault' | 'default';
digestSource: 'vault' | 'default';
} }
export function getCsrfConfigSummary(): CsrfConfigSummary | null { export function getCsrfConfigSummary(): CsrfConfigSummary | null {
@@ -50,6 +58,8 @@ export function getCsrfConfigSummary(): CsrfConfigSummary | null {
digest: current.digest, digest: current.digest,
maxAgeSec: current.maxAgeSec, maxAgeSec: current.maxAgeSec,
secretFp: csrfSecretFingerprint(current.secret), secretFp: csrfSecretFingerprint(current.secret),
saltSource: current.saltFromVault ? 'vault' : 'default',
digestSource: current.digestFromVault ? 'vault' : 'default',
}; };
} }
@@ -59,8 +69,14 @@ export function logCsrfConfigLoaded(): void {
logger.info( logger.info(
`CSRF config loaded: mount=${summary.mount} path=${summary.path} ` + `CSRF config loaded: mount=${summary.mount} path=${summary.path} ` +
`salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` + `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}`,
); );
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( export async function fetchCsrfConfig(
@@ -80,19 +96,18 @@ export async function fetchCsrfConfig(
throw new Error('CSRF secret invalid: must be string >= 32 chars'); throw new Error('CSRF secret invalid: must be string >= 32 chars');
} }
const salt = secrets.salt; let saltFromVault = false;
if (!salt || typeof salt !== 'string' || salt.length < 4) { let salt = DEFAULT_SALT;
throw new Error( if (secrets.salt && typeof secrets.salt === 'string' && secrets.salt.length >= 1) {
'CSRF salt required in Vault KV (field "salt", min 4 chars). ' + salt = secrets.salt;
'Example: vault kv patch -mount=<mount> <csrf_path> salt=csrf-salt digest=sha256', saltFromVault = true;
);
} }
let digestFromVault = false;
let digest: 'sha256' | 'sha512' = 'sha256'; let digest: 'sha256' | 'sha512' = 'sha256';
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') { if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
digest = secrets.digest; digest = secrets.digest;
} else if (secrets.digest) { digestFromVault = true;
throw new Error(`CSRF digest invalid: ${secrets.digest} (allowed: sha256, sha512)`);
} }
let maxAgeSec = 60 * 60 * 24 * 7; let maxAgeSec = 60 * 60 * 24 * 7;
@@ -101,7 +116,7 @@ export async function fetchCsrfConfig(
if (!Number.isNaN(n) && n > 0) maxAgeSec = n; 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 { function b64urlDecode(s: string): Buffer {
@@ -130,6 +145,16 @@ export interface CsrfVerifyResult {
type CsrfDigest = 'sha1' | 'sha256' | 'sha512'; type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
const DIGEST_BY_SIG_LEN: Record<number, CsrfDigest> = {
20: 'sha1',
32: 'sha256',
64: 'sha512',
};
const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512'];
const MIN_VERIFY_SALT_LEN = 1;
function verifyCsrfTokenWithParams( function verifyCsrfTokenWithParams(
cfg: CsrfConfig, cfg: CsrfConfig,
salt: string, salt: string,
@@ -183,41 +208,77 @@ function verifyCsrfTokenWithParams(
return { valid: true }; return { valid: true };
} }
function allowSha1VerifyBridge(): boolean { function saltsToTry(vaultSalt: string): string[] {
return process.env.CSRF_ALLOW_SHA1_VERIFY === 'true'; 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. * Verify: Vault salt+digest first, then legacy matrix (same secret_key from Vault, read-only).
* Optional bridge: CSRF_ALLOW_SHA1_VERIFY=true tries sha1 + same Vault salt (legacy auth, one release).
*/ */
export function verifyCsrfToken(token: string): CsrfVerifyResult { export function verifyCsrfToken(token: string): CsrfVerifyResult {
if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; 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 (primary.valid) return primary;
if ( if (!isRetryableVerifyFailure(primary.reason)) {
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 (primary.reason === 'Signature mismatch') {
return { valid: false, reason: 'Signature mismatch (Vault secret/salt does not match token)' };
}
return primary; 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;
}
}
}
return {
valid: false,
reason: 'Signature mismatch (all digest/salt fallbacks failed)',
actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen,
expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen,
};
}