From a690c1bc2c567ece7364ff77b1d02b73d36a7b86 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:19:43 +0330 Subject: [PATCH] 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. --- app/backend/src/plugins/csrf.ts | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 app/backend/src/plugins/csrf.ts diff --git a/app/backend/src/plugins/csrf.ts b/app/backend/src/plugins/csrf.ts new file mode 100644 index 0000000..aa403ad --- /dev/null +++ b/app/backend/src/plugins/csrf.ts @@ -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 {} + }); +});