security hardening

This commit is contained in:
2025-12-28 20:04:29 -05:00
parent aefd467660
commit 1ba20339a8
22 changed files with 5177 additions and 116 deletions

401
src/server/security.ts Normal file
View File

@@ -0,0 +1,401 @@
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<string, string>
);
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<string, RateLimitRecord>();
/**
* 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
);
}