Coves frontend - a photon fork
at main 156 lines 5.4 kB view raw
1import { randomBytes, timingSafeEqual } from 'crypto' 2 3// ============================================================================ 4// Branded Types for Type-Safe CSRF State 5// ============================================================================ 6 7/** 8 * Branded type for OAuth CSRF state tokens. 9 * These are 64-character hex strings (32 bytes of entropy). 10 */ 11export type OAuthState = string & { readonly __brand: 'OAuthState' } 12 13/** 14 * Type guard to validate OAuthState format. 15 * State tokens must be 64-character hexadecimal strings. 16 * 17 * @param value - The string to validate 18 * @returns True if the value matches the OAuthState format 19 */ 20export function isValidOAuthState(value: string): value is OAuthState { 21 return /^[a-f0-9]{64}$/.test(value) 22} 23 24/** 25 * Creates a branded OAuthState from a string. 26 * @throws Error if the value is not a valid OAuthState format 27 */ 28export function asOAuthState(value: string): OAuthState { 29 if (!isValidOAuthState(value)) { 30 throw new Error(`Invalid OAuthState format: expected 64 hex characters, got ${value.length} characters`) 31 } 32 return value 33} 34 35/** 36 * Safely attempts to create a branded OAuthState from a string. 37 * @returns The branded OAuthState or null if invalid 38 */ 39export function tryAsOAuthState(value: string): OAuthState | null { 40 return isValidOAuthState(value) ? value : null 41} 42 43// ============================================================================ 44// State Generation 45// ============================================================================ 46 47/** 48 * Generates a cryptographically secure random state for OAuth CSRF protection. 49 * Uses crypto.randomBytes(32) for 256 bits of entropy. 50 * 51 * @returns A 64-character hex string as an OAuthState branded type 52 */ 53export function generateOAuthState(): OAuthState { 54 return randomBytes(32).toString('hex') as OAuthState 55} 56 57// ============================================================================ 58// State Validation 59// ============================================================================ 60 61/** 62 * Validates that two OAuth state strings match using a timing-safe comparison. 63 * This prevents timing attacks that could be used to infer state values. 64 * 65 * @param expected - The expected state value (stored in session) 66 * @param actual - The actual state value (received from callback) 67 * @returns True if the states match, false otherwise 68 */ 69export function validateOAuthState(expected: string, actual: string): boolean { 70 // Early return on length mismatch is acceptable here because: 71 // 1. The state parameter is visible in the URL, so attackers already know its length 72 // 2. Valid OAuth states are always 64 hex characters, so length leakage reveals nothing 73 // 3. The actual value comparison below uses timing-safe comparison 74 if (expected.length !== actual.length) { 75 return false 76 } 77 78 // Use timing-safe comparison to prevent timing attacks 79 const expectedBuffer = Buffer.from(expected, 'utf8') 80 const actualBuffer = Buffer.from(actual, 'utf8') 81 82 return timingSafeEqual(expectedBuffer, actualBuffer) 83} 84 85// ============================================================================ 86// Origin Validation 87// ============================================================================ 88 89/** 90 * Result of origin validation check. 91 */ 92export interface OriginValidationResult { 93 /** Whether the origin is valid (same-origin or no origin header) */ 94 valid: boolean 95 /** Reason for the validation result (useful for logging) */ 96 reason?: string 97} 98 99/** 100 * Validates that a request originates from the expected origin. 101 * Checks the Origin header first, falling back to the Referer header. 102 * 103 * This helps prevent CSRF attacks by ensuring requests come from the same origin. 104 * If neither Origin nor Referer headers are present, the request is considered valid 105 * because some browsers strip these headers for privacy reasons. 106 * 107 * @param request - The incoming request object 108 * @param expectedOrigin - The expected origin URL (e.g., "https://example.com") 109 * @returns An object with valid status and optional reason 110 */ 111export function validateRequestOrigin( 112 request: Request, 113 expectedOrigin: string 114): OriginValidationResult { 115 const origin = request.headers.get('Origin') 116 const referer = request.headers.get('Referer') 117 118 // Check Origin header first (preferred) 119 if (origin) { 120 if (origin === expectedOrigin) { 121 return { valid: true, reason: 'Origin header matches expected origin' } 122 } 123 return { 124 valid: false, 125 reason: `Origin mismatch: expected "${expectedOrigin}", got "${origin}"`, 126 } 127 } 128 129 // Fall back to Referer header 130 if (referer) { 131 try { 132 const refererUrl = new URL(referer) 133 const refererOrigin = refererUrl.origin 134 135 if (refererOrigin === expectedOrigin) { 136 return { valid: true, reason: 'Referer origin matches expected origin' } 137 } 138 return { 139 valid: false, 140 reason: `Referer origin mismatch: expected "${expectedOrigin}", got "${refererOrigin}"`, 141 } 142 } catch { 143 return { 144 valid: false, 145 reason: `Invalid Referer URL: "${referer}"`, 146 } 147 } 148 } 149 150 // No Origin or Referer header - accept the request 151 // Some browsers strip these headers for privacy, so we can't reject 152 return { 153 valid: true, 154 reason: 'No Origin or Referer header present (accepted for browser compatibility)', 155 } 156}