Import source: app/backend/src/plugins/csrf
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { redis } from '../redis.js';
|
||||||
|
import { logSecurityEvent } from '../lib/security-audit.js';
|
||||||
|
import { sessionKey, CSRF_COOKIE, csrfCookieOpts } from './session.js';
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
|
const EXEMPT_PATHS = new Set([
|
||||||
|
'/api/v1/auth/login',
|
||||||
|
'/api/v1/auth/signup',
|
||||||
|
'/api/v1/auth/forgot-password',
|
||||||
|
'/api/v1/auth/reset-password',
|
||||||
|
'/api/v1/auth/otp/send',
|
||||||
|
'/api/v1/auth/otp/verify',
|
||||||
|
'/api/v1/auth/2fa/verify',
|
||||||
|
'/api/v1/newsletter',
|
||||||
|
'/api/v1/analytics/events',
|
||||||
|
'/api/v1/errors',
|
||||||
|
'/api/v1/jobs',
|
||||||
|
'/api/v1/csp-report',
|
||||||
|
]);
|
||||||
|
const EXEMPT_PREFIX = '/api/v1/auth/oauth/';
|
||||||
|
|
||||||
|
export default fp(async function csrfPlugin(app: FastifyInstance) {
|
||||||
|
app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (SAFE_METHODS.has(req.method)) return;
|
||||||
|
const path = req.url.split('?')[0];
|
||||||
|
if (EXEMPT_PATHS.has(path) || path.startsWith(EXEMPT_PREFIX)) return;
|
||||||
|
|
||||||
|
if (!req.session) {
|
||||||
|
return reply.code(401).send({ error: 'unauthenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerToken = req.headers['x-csrf-token'];
|
||||||
|
if (typeof headerToken !== 'string' || headerToken.length === 0) {
|
||||||
|
await logSecurityEvent({ type: 'csrf_failure', userId: req.session.userId, ip: req.ip, userAgent: req.headers['user-agent'], metadata: { reason: 'missing' } });
|
||||||
|
return reply.code(403).send({ error: 'csrf_missing' });
|
||||||
|
}
|
||||||
|
if (headerToken !== req.session.csrfToken) {
|
||||||
|
await logSecurityEvent({ type: 'csrf_failure', userId: req.session.userId, ip: req.ip, userAgent: req.headers['user-agent'], metadata: { reason: 'mismatch' } });
|
||||||
|
return reply.code(403).send({ error: 'csrf_mismatch' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.addHook('onResponse', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (SAFE_METHODS.has(req.method)) return;
|
||||||
|
if (!req.session?.sid || reply.statusCode >= 400) return;
|
||||||
|
|
||||||
|
const { randomBytes } = await import('node:crypto');
|
||||||
|
const newToken = randomBytes(16).toString('base64url');
|
||||||
|
const raw = await redis.get(sessionKey(req.session.sid));
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
data.csrfToken = newToken;
|
||||||
|
await redis.set(sessionKey(req.session.sid), JSON.stringify(data));
|
||||||
|
reply.setCookie(CSRF_COOKIE, newToken, { ...csrfCookieOpts, maxAge: 14 * 24 * 60 * 60 });
|
||||||
|
reply.header('X-CSRF-Token', newToken);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user