forked from Mike/freno-dev
402 lines
9.8 KiB
TypeScript
402 lines
9.8 KiB
TypeScript
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
|
|
);
|
|
}
|