feat: add csrf

This commit is contained in:
2026-04-19 11:32:47 +03:00
parent 17855ecd87
commit 517df542e1
9 changed files with 4827 additions and 8 deletions

View File

@@ -0,0 +1,115 @@
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import { SignJWT, jwtVerify } from 'jose';
import { fetchVaultKV2 } from '../config/vault';
import { env, getVaultToken } from '../config/env';
import { logger } from '../lib/logger';
export const CSRF_COOKIE_NAME = 'csrf_token';
export const CSRF_HEADER_NAME = 'X-CSRF-Token';
const TTL_SECONDS = 3600;
let signingKeyBytes: Uint8Array | null = null;
export function setCsrfSigningKey(secret: string): void {
if (secret.length < 32) {
throw new Error('CSRF secret must be at least 32 characters');
}
signingKeyBytes = new Uint8Array(createHash('sha256').update(secret, 'utf8').digest());
}
export function hasCsrfSigningKey(): boolean {
return signingKeyBytes !== null;
}
function getKey(): Uint8Array {
if (!signingKeyBytes) {
throw new Error('CSRF signing key not configured');
}
return signingKeyBytes;
}
export async function issueCsrfToken(): Promise<string> {
const nonce = randomBytes(24).toString('base64url');
const now = Math.floor(Date.now() / 1000);
return new SignJWT({ scope: 'csrf', nce: nonce })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now)
.setNotBefore(now)
.setExpirationTime(now + TTL_SECONDS)
.sign(getKey());
}
export async function verifyCsrfPair(
cookieToken: string | undefined,
headerToken: string | undefined,
): Promise<void> {
if (!cookieToken || !headerToken) {
const e = new Error('CSRF token missing') as Error & { status: number };
e.status = 403;
throw e;
}
const a = Buffer.from(cookieToken);
const b = Buffer.from(headerToken);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
const e = new Error('CSRF token mismatch') as Error & { status: number };
e.status = 403;
throw e;
}
try {
const { payload } = await jwtVerify(cookieToken, getKey(), {
algorithms: ['HS256'],
clockTolerance: 10,
});
if (payload.scope !== 'csrf') {
throw new Error('invalid');
}
} catch {
const e = new Error('CSRF token invalid') as Error & { status: number };
e.status = 403;
throw e;
}
}
export function getCsrfCookieMaxAgeMs(): number {
return TTL_SECONDS * 1000;
}
export async function loadCsrfSecretFromVault(): Promise<boolean> {
const token = getVaultToken();
if (!token || !env.vault.addr) {
return false;
}
const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.csrfSecretPath);
const key = data?.CSRF_SECRET_KEY;
if (!key || key.length < 32) {
logger.warn('Vault CSRF secret missing or too short');
return false;
}
setCsrfSigningKey(key);
logger.info('CSRF signing key loaded from Vault');
return true;
}
export function finalizeCsrfConfigFromEnv(): void {
if (hasCsrfSigningKey()) {
return;
}
const k = process.env.CSRF_SECRET_KEY;
if (k && k.length >= 32) {
setCsrfSigningKey(k);
logger.info('CSRF signing key loaded from environment');
return;
}
logger.error('CSRF_SECRET_KEY is required (Vault path or env, min 32 characters)');
process.exit(1);
}