initluyhguliohw3eufuer
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user