security hardening
This commit is contained in:
401
src/server/security.ts
Normal file
401
src/server/security.ts
Normal 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user