← 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 };