All files / src/lib/security csrf.ts

42.69% Statements 73/171
0% Branches 0/1
0% Functions 0/10
42.69% Lines 73/171

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 1721x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                     1x 1x 1x 1x                                                           1x 1x 1x 1x 1x                           1x 1x 1x 1x 1x       1x 1x 1x 1x       1x 1x 1x 1x 1x 1x                                 1x 1x 1x 1x 1x 1x                 1x 1x 1x 1x         1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x       1x 1x 1x 1x 1x 1x                    
/**
 * CSRF Protection Service
 *
 * Implements double-submit cookie pattern for CSRF protection.
 * Uses HMAC-signed tokens with timestamps for verification.
 */
 
import { cookies } from 'next/headers';
import crypto from 'crypto';
 
const CSRF_SECRET = process.env.CSRF_SECRET || process.env.NEXTAUTH_SECRET || 'fallback-csrf-secret-change-me';
const CSRF_COOKIE_NAME = '__csrf';
const CSRF_HEADER_NAME = 'x-csrf-token';
const CSRF_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
 
/**
 * Generate a signed CSRF token
 */
export function generateCsrfToken(): string {
  const token = crypto.randomBytes(32).toString('hex');
  const timestamp = Date.now().toString(36);
  const signature = crypto
    .createHmac('sha256', CSRF_SECRET)
    .update(`${token}:${timestamp}`)
    .digest('hex');

  return `${token}:${timestamp}:${signature}`;
}
 
/**
 * Validate a CSRF token
 */
export function validateCsrfToken(token: string | null | undefined): boolean {
  if (!token) return false;

  const parts = token.split(':');
  if (parts.length !== 3) return false;

  const [tokenPart, timestamp, signature] = parts;

  // Validate signature
  const expectedSignature = crypto
    .createHmac('sha256', CSRF_SECRET)
    .update(`${tokenPart}:${timestamp}`)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  try {
    if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
      return false;
    }
  } catch {
    return false;
  }

  // Validate timestamp (token not expired)
  const tokenTime = parseInt(timestamp, 36);
  const now = Date.now();

  return now - tokenTime < CSRF_TOKEN_EXPIRY_MS;
}
 
/**
 * Set CSRF token in cookie and return it
 */
export async function setCsrfCookie(): Promise<string> {
  const token = generateCsrfToken();
  const cookieStore = await cookies();

  cookieStore.set(CSRF_COOKIE_NAME, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: CSRF_TOKEN_EXPIRY_MS / 1000,
    path: '/',
  });

  return token;
}
 
/**
 * Get CSRF token from cookie
 */
export async function getCsrfCookie(): Promise<string | undefined> {
  const cookieStore = await cookies();
  return cookieStore.get(CSRF_COOKIE_NAME)?.value;
}
 
/**
 * Get CSRF token from request header
 */
export function getCsrfHeader(request: Request): string | null {
  return request.headers.get(CSRF_HEADER_NAME);
}
 
/**
 * Verify CSRF token from request
 * Compares header token with cookie token and validates signature
 */
export async function verifyCsrfToken(request: Request): Promise<boolean> {
  const headerToken = getCsrfHeader(request);
  const cookieToken = await getCsrfCookie();

  // Both tokens must exist
  if (!headerToken || !cookieToken) {
    return false;
  }

  // Tokens must match
  if (headerToken !== cookieToken) {
    return false;
  }

  // Token must be valid (signature and expiry)
  return validateCsrfToken(headerToken);
}
 
/**
 * Get or create CSRF token
 * Use this in server components to provide token to client
 */
export async function getOrCreateCsrfToken(): Promise<string> {
  const existingToken = await getCsrfCookie();

  if (existingToken && validateCsrfToken(existingToken)) {
    return existingToken;
  }

  return setCsrfCookie();
}
 
/**
 * Check if request method requires CSRF protection
 */
export function requiresCsrfProtection(method: string): boolean {
  const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
  return !safeMethods.includes(method.toUpperCase());
}
 
/**
 * CSRF exempt paths (webhooks, OAuth callbacks, etc.)
 */
const CSRF_EXEMPT_PATHS = [
  '/api/webhooks',
  '/api/auth/callback',
  '/api/auth/signin',
  '/api/auth/signout',
  '/api/auth/csrf',
];
 
/**
 * Check if path is exempt from CSRF protection
 */
export function isCsrfExempt(pathname: string): boolean {
  return CSRF_EXEMPT_PATHS.some((path) => pathname.startsWith(path));
}
 
/**
 * Create a CSRF token for forms
 * Returns token and hidden input HTML
 */
export async function getCsrfFormData(): Promise<{
  token: string;
  inputHtml: string;
}> {
  const token = await getOrCreateCsrfToken();
  return {
    token,
    inputHtml: `<input type="hidden" name="_csrf" value="${token}" />`,
  };
}