← back to Wine Finder Next
lib/csrf.ts
235 lines
// CSRF Protection Implementation
// Cross-Site Request Forgery prevention using double-submit cookie pattern
import crypto from 'crypto';
interface CSRFToken {
token: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
sessionId?: string;
}
// Token storage - in production use Redis
const csrfTokenStore = new Map<string, CSRFToken>();
// Configuration
const CSRF_CONFIG = {
tokenLength: 32,
tokenLifetime: 4 * 60 * 60 * 1000, // 4 hours
sameSitePolicy: 'strict' as const,
secureCookie: process.env.NODE_ENV === 'production',
cookieName: 'wine-csrf-token',
headerName: 'x-csrf-token',
cleanupInterval: 60 * 60 * 1000 // 1 hour
};
// Generate CSRF token
export function generateCSRFToken(sessionId: string, ipAddress: string): string {
const token = crypto.randomBytes(CSRF_CONFIG.tokenLength).toString('hex');
const now = new Date();
const csrfToken: CSRFToken = {
token,
createdAt: now,
expiresAt: new Date(now.getTime() + CSRF_CONFIG.tokenLifetime),
ipAddress,
sessionId
};
csrfTokenStore.set(token, csrfToken);
// Cleanup old tokens for this session
cleanupSessionTokens(sessionId);
return token;
}
// Verify CSRF token
export function verifyCSRFToken(
token: string,
sessionId?: string,
ipAddress?: string
): { valid: boolean; reason?: string } {
if (!token) {
return { valid: false, reason: 'Missing CSRF token' };
}
const storedToken = csrfTokenStore.get(token);
if (!storedToken) {
return { valid: false, reason: 'Invalid CSRF token' };
}
// Check expiration
if (new Date() > storedToken.expiresAt) {
csrfTokenStore.delete(token);
return { valid: false, reason: 'CSRF token expired' };
}
// Verify session ID if provided
if (sessionId && storedToken.sessionId !== sessionId) {
return { valid: false, reason: 'CSRF token session mismatch' };
}
// Verify IP address if provided (optional - can be strict)
if (ipAddress && storedToken.ipAddress !== ipAddress) {
// Log this as suspicious but don't necessarily block
console.warn('[CSRF] IP address mismatch:', {
expected: storedToken.ipAddress,
actual: ipAddress,
token: token.substring(0, 8) + '...'
});
}
return { valid: true };
}
// Extract CSRF token from request
export function extractCSRFToken(headers: Headers | Record<string, string>): string | null {
// Check header first (preferred)
const headerToken = headers instanceof Headers
? headers.get(CSRF_CONFIG.headerName)
: headers[CSRF_CONFIG.headerName];
if (headerToken) {
return headerToken;
}
// Check cookie as fallback
const cookieHeader = headers instanceof Headers
? headers.get('cookie')
: headers['cookie'];
if (cookieHeader) {
const cookies = parseCookies(cookieHeader);
return cookies[CSRF_CONFIG.cookieName] || null;
}
return null;
}
// Parse cookies from header
function parseCookies(cookieHeader: string): Record<string, string> {
const cookies: Record<string, string> = {};
cookieHeader.split(';').forEach(cookie => {
const [name, value] = cookie.trim().split('=');
if (name && value) {
cookies[name] = decodeURIComponent(value);
}
});
return cookies;
}
// Generate CSRF cookie header
export function generateCSRFCookie(token: string): string {
const parts = [
`${CSRF_CONFIG.cookieName}=${token}`,
'HttpOnly',
`SameSite=${CSRF_CONFIG.sameSitePolicy}`,
'Path=/',
`Max-Age=${CSRF_CONFIG.tokenLifetime / 1000}`
];
if (CSRF_CONFIG.secureCookie) {
parts.push('Secure');
}
return parts.join('; ');
}
// Cleanup expired tokens
function cleanupExpiredTokens(): void {
const now = new Date();
for (const [token, data] of csrfTokenStore.entries()) {
if (now > data.expiresAt) {
csrfTokenStore.delete(token);
}
}
}
// Cleanup tokens for a specific session
function cleanupSessionTokens(sessionId: string): void {
const tokensToDelete: string[] = [];
const now = new Date();
for (const [token, data] of csrfTokenStore.entries()) {
if (data.sessionId === sessionId && now > data.expiresAt) {
tokensToDelete.push(token);
}
}
tokensToDelete.forEach(token => csrfTokenStore.delete(token));
}
// CSRF protection middleware helper
export function requireCSRF(
request: { headers: Headers | Record<string, string> },
sessionId?: string,
ipAddress?: string
): { success: boolean; error?: string } {
// Skip CSRF check for safe methods
const method = request.headers instanceof Headers
? request.headers.get('x-http-method') || 'GET'
: request.headers['x-http-method'] || 'GET';
if (['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) {
return { success: true };
}
const token = extractCSRFToken(request.headers);
if (!token) {
return { success: false, error: 'CSRF token required for state-changing operations' };
}
const verification = verifyCSRFToken(token, sessionId, ipAddress);
if (!verification.valid) {
return { success: false, error: verification.reason };
}
return { success: true };
}
// Invalidate CSRF token
export function invalidateCSRFToken(token: string): void {
csrfTokenStore.delete(token);
}
// Get CSRF token info (for debugging)
export function getCSRFTokenInfo(token: string): CSRFToken | undefined {
return csrfTokenStore.get(token);
}
// Get CSRF stats
export function getCSRFStats(): {
totalTokens: number;
expiredTokens: number;
activeTokens: number;
} {
const now = new Date();
let expiredCount = 0;
for (const data of csrfTokenStore.values()) {
if (now > data.expiresAt) {
expiredCount++;
}
}
return {
totalTokens: csrfTokenStore.size,
expiredTokens: expiredCount,
activeTokens: csrfTokenStore.size - expiredCount
};
}
// Run cleanup periodically
setInterval(cleanupExpiredTokens, CSRF_CONFIG.cleanupInterval);
// Export configuration for testing
export { CSRF_CONFIG };