feat: add csrf
This commit is contained in:
115
apps/api/src/services/csrf.service.ts
Normal file
115
apps/api/src/services/csrf.service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user