Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
import fp from 'fastify-plugin';
|
|
import { isProd, env } from '../env';
|
|
import { SESSION_COOKIE } from './sessions';
|
|
|
|
export const CSRF_COOKIE = 'csrf';
|
|
export const CSRF_HEADER = 'x-csrf-token';
|
|
const TOKEN_BYTES = 32;
|
|
|
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
|
|
// Routes that legitimately bypass CSRF — they receive their own auth (signature check)
|
|
// or have no session yet, so a CSRF attack against them is meaningless.
|
|
const CSRF_EXEMPT_PREFIXES = ['/api/auth/', '/api/contact', '/api/webhooks/', '/api/tool-usage'];
|
|
|
|
export function generateCsrfToken(): string {
|
|
return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
|
|
}
|
|
|
|
function constantTimeEqual(a: string, b: string): boolean {
|
|
const ab = Buffer.from(a);
|
|
const bb = Buffer.from(b);
|
|
if (ab.length !== bb.length) return false;
|
|
return crypto.timingSafeEqual(ab, bb);
|
|
}
|
|
|
|
declare module 'fastify' {
|
|
interface FastifyInstance {
|
|
setCsrfCookie: (reply: FastifyReply, token: string) => void;
|
|
clearCsrfCookie: (reply: FastifyReply) => void;
|
|
}
|
|
}
|
|
|
|
async function plugin(app: FastifyInstance) {
|
|
app.decorate('setCsrfCookie', (reply: FastifyReply, token: string) => {
|
|
reply.setCookie(CSRF_COOKIE, token, {
|
|
path: '/',
|
|
httpOnly: false, // intentional — JS reads this and echoes it as a header
|
|
secure: isProd,
|
|
sameSite: 'lax',
|
|
domain: env.COOKIE_DOMAIN || undefined,
|
|
});
|
|
});
|
|
|
|
app.decorate('clearCsrfCookie', (reply: FastifyReply) => {
|
|
reply.clearCookie(CSRF_COOKIE, {
|
|
path: '/',
|
|
secure: isProd,
|
|
sameSite: 'lax',
|
|
domain: env.COOKIE_DOMAIN || undefined,
|
|
});
|
|
});
|
|
|
|
// Auto-mint a CSRF token whenever an authenticated session exists but no CSRF cookie is set.
|
|
// This makes the protection self-bootstrapping after sessions created before CSRF was enabled.
|
|
app.addHook('onRequest', async (req, reply) => {
|
|
if (!req.cookies?.[SESSION_COOKIE]) return;
|
|
if (req.cookies?.[CSRF_COOKIE]) return;
|
|
const token = generateCsrfToken();
|
|
app.setCsrfCookie(reply, token);
|
|
req.cookies = { ...req.cookies, [CSRF_COOKIE]: token };
|
|
});
|
|
|
|
// Verify CSRF on every state-changing request that has a session cookie.
|
|
app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => {
|
|
if (SAFE_METHODS.has(req.method)) return;
|
|
if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect
|
|
const url = req.routeOptions.url || req.url;
|
|
if (CSRF_EXEMPT_PREFIXES.some((p) => url.startsWith(p))) return;
|
|
|
|
const cookie = req.cookies?.[CSRF_COOKIE];
|
|
const header = (req.headers[CSRF_HEADER] as string | undefined) ?? '';
|
|
if (!cookie || !header || !constantTimeEqual(cookie, header)) {
|
|
return reply.code(403).send({ error: 'csrf_failed' });
|
|
}
|
|
});
|
|
}
|
|
|
|
export const csrfPlugin = fp(plugin, { name: 'csrf', dependencies: ['auth'] });
|