import { TRPCError } from "@trpc/server"; import { getCookie, setCookie } from "vinxi/http"; import type { H3Event } from "vinxi/http"; import { t } from "~/server/api/utils"; import { logAuditEvent } from "~/server/audit"; /** * Extract cookie value from H3Event (works in both production and tests) */ function getCookieValue(event: H3Event, name: string): string | undefined { try { // Try vinxi's getCookie first const value = getCookie(event, name); if (value) return value; } catch (e) { // vinxi's getCookie failed, will use fallback } // Fallback for tests: parse cookie header manually try { const cookieHeader = event.headers?.get?.("cookie") || (event.headers as any)?.cookie || event.node?.req?.headers?.cookie || ""; const cookies = cookieHeader .split(";") .map((c) => c.trim()) .reduce( (acc, cookie) => { const [key, value] = cookie.split("="); if (key && value) acc[key] = value; return acc; }, {} as Record ); return cookies[name]; } catch { return undefined; } } /** * Set cookie (works in both production and tests) */ function setCookieValue( event: H3Event, name: string, value: string, options: any ): void { try { setCookie(event, name, value, options); } catch (e) { // In tests, setCookie might fail - store in mock object if (!event.node) event.node = { req: { headers: {} } } as any; if (!event.node.res) event.node.res = {} as any; if (!event.node.res.cookies) event.node.res.cookies = {} as any; event.node.res.cookies[name] = value; } } /** * Extract header value from H3Event (works in both production and tests) */ function getHeaderValue(event: H3Event, name: string): string | null { try { // Try various header access patterns if (event.request?.headers?.get) { const val = event.request.headers.get(name); if (val !== null && val !== undefined) return val; } if (event.headers) { // Check if it's a Headers object with .get method if (typeof (event.headers as any).get === "function") { const val = (event.headers as any).get(name); if (val !== null && val !== undefined) return val; } // Or a plain object if (typeof event.headers === "object") { const val = (event.headers as any)[name]; if (val !== undefined) return val; } } if (event.node?.req?.headers) { const val = event.node.req.headers[name]; if (val !== undefined) return val; } return null; } catch { return null; } } /** * Generate a cryptographically secure CSRF token */ export function generateCSRFToken(): string { return crypto.randomUUID(); } /** * Set CSRF token cookie */ export function setCSRFToken(event: H3Event): string { const token = generateCSRFToken(); setCookieValue(event, "csrf-token", token, { maxAge: 60 * 60 * 24 * 14, // 14 days - same as session path: "/", httpOnly: false, // Must be readable by client JS secure: true, // Always enforce secure sameSite: "lax" }); return token; } /** * Validate CSRF token from request header against cookie */ export function validateCSRFToken(event: H3Event): boolean { const headerToken = getHeaderValue(event, "x-csrf-token"); const cookieToken = getCookieValue(event, "csrf-token"); if (!headerToken || !cookieToken) { return false; } // Constant-time comparison to prevent timing attacks return timingSafeEqual(headerToken, cookieToken); } /** * Timing-safe string comparison */ function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) { return false; } let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); } return result === 0; } /** * CSRF protection middleware for tRPC * Use this on all mutation procedures that modify state */ export const csrfProtection = t.middleware(async ({ ctx, next }) => { const isValid = validateCSRFToken(ctx.event.nativeEvent); if (!isValid) { // Log CSRF failure const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent); await logAuditEvent({ eventType: "security.csrf.failed", eventData: { headerToken: getHeaderValue(ctx.event.nativeEvent, "x-csrf-token") ? "present" : "missing", cookieToken: getCookieValue(ctx.event.nativeEvent, "csrf-token") ? "present" : "missing" }, ipAddress, userAgent, success: false }); throw new TRPCError({ code: "FORBIDDEN", message: "Invalid CSRF token" }); } return next(); }); /** * Protected procedure with CSRF validation */ export const csrfProtectedProcedure = t.procedure.use(csrfProtection); // ========== Rate Limiting ========== interface RateLimitRecord { count: number; resetAt: number; } /** * In-memory rate limit store * In production, consider using Redis for distributed rate limiting */ const rateLimitStore = new Map(); /** * Clear rate limit store (for testing only) */ export function clearRateLimitStore(): void { rateLimitStore.clear(); } /** * Cleanup expired rate limit entries every 5 minutes */ setInterval( () => { const now = Date.now(); for (const [key, record] of rateLimitStore.entries()) { if (now > record.resetAt) { rateLimitStore.delete(key); } } }, 5 * 60 * 1000 ); /** * Get client IP address from request headers */ export function getClientIP(event: H3Event): string { const forwarded = getHeaderValue(event, "x-forwarded-for"); if (forwarded) { return forwarded.split(",")[0].trim(); } const realIP = getHeaderValue(event, "x-real-ip"); if (realIP) { return realIP; } return "unknown"; } /** * Get user agent from request headers */ export function getUserAgent(event: H3Event): string { return getHeaderValue(event, "user-agent") || "unknown"; } /** * Extract audit context from H3Event * Convenience function for logging */ export function getAuditContext(event: H3Event): { ipAddress: string; userAgent: string; } { return { ipAddress: getClientIP(event), userAgent: getUserAgent(event) }; } /** * Check rate limit for a given identifier * @param identifier - Unique identifier (e.g., "login:ip:192.168.1.1") * @param maxAttempts - Maximum number of attempts allowed * @param windowMs - Time window in milliseconds * @param event - H3Event for audit logging (optional) * @returns Remaining attempts before limit is hit * @throws TRPCError if rate limit exceeded */ export function checkRateLimit( identifier: string, maxAttempts: number, windowMs: number, event?: H3Event ): number { const now = Date.now(); const record = rateLimitStore.get(identifier); if (!record || now > record.resetAt) { // Create new record rateLimitStore.set(identifier, { count: 1, resetAt: now + windowMs }); return maxAttempts - 1; } if (record.count >= maxAttempts) { const remainingMs = record.resetAt - now; const remainingSec = Math.ceil(remainingMs / 1000); // Log rate limit exceeded (fire-and-forget) if (event) { const { ipAddress, userAgent } = getAuditContext(event); logAuditEvent({ eventType: "security.rate_limit.exceeded", eventData: { identifier, maxAttempts, windowMs, remainingSec }, ipAddress, userAgent, success: false }).catch(() => { // Ignore logging errors }); } throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: `Too many attempts. Try again in ${remainingSec} seconds` }); } // Increment count record.count++; return maxAttempts - record.count; } /** * Rate limit configuration for different operations */ export const RATE_LIMITS = { // Login: 5 attempts per 15 minutes per IP LOGIN_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }, // Login: 3 attempts per hour per email LOGIN_EMAIL: { maxAttempts: 3, windowMs: 60 * 60 * 1000 }, // Password reset: 3 attempts per hour per IP PASSWORD_RESET_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 }, // Registration: 3 attempts per hour per IP REGISTRATION_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 }, // Email verification: 5 attempts per 15 minutes per IP EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 } } as const; /** * Rate limiting middleware for login operations */ export function rateLimitLogin( email: string, clientIP: string, event?: H3Event ): void { // Rate limit by IP checkRateLimit( `login:ip:${clientIP}`, RATE_LIMITS.LOGIN_IP.maxAttempts, RATE_LIMITS.LOGIN_IP.windowMs, event ); // Rate limit by email checkRateLimit( `login:email:${email}`, RATE_LIMITS.LOGIN_EMAIL.maxAttempts, RATE_LIMITS.LOGIN_EMAIL.windowMs, event ); } /** * Rate limiting middleware for password reset */ export function rateLimitPasswordReset( clientIP: string, event?: H3Event ): void { checkRateLimit( `password-reset:ip:${clientIP}`, RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts, RATE_LIMITS.PASSWORD_RESET_IP.windowMs, event ); } /** * Rate limiting middleware for registration */ export function rateLimitRegistration(clientIP: string, event?: H3Event): void { checkRateLimit( `registration:ip:${clientIP}`, RATE_LIMITS.REGISTRATION_IP.maxAttempts, RATE_LIMITS.REGISTRATION_IP.windowMs, event ); } /** * Rate limiting middleware for email verification */ export function rateLimitEmailVerification( clientIP: string, event?: H3Event ): void { checkRateLimit( `email-verification:ip:${clientIP}`, RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts, RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs, event ); }