Advertisement

← vibesdk

worker/services/csrf/CsrfService.ts

8.9 KB Download file
/**
 * CSRF Protection Service
 * Implements double-submit cookie pattern for CSRF protection
 */

import { createLogger } from '../../logger';
import { SecurityError, SecurityErrorType } from 'shared/types/errors';
import { generateSecureToken } from '../../utils/cryptoUtils';
import { parseCookies, createSecureCookie } from '../../utils/authUtils';
import { getCSRFConfig } from '../../config/security';
import { captureSecurityEvent } from '../../observability/sentry';
import { env } from 'cloudflare:workers'

const logger = createLogger('CsrfService');

interface CSRFTokenData {
    token: string;
    timestamp: number;
}

export class CsrfService {
    static readonly COOKIE_NAME = 'csrf-token';
    static readonly HEADER_NAME = 'X-CSRF-Token';
    static readonly defaults = getCSRFConfig(env)
    
    /**
     * Generate a cryptographically secure CSRF token
     */
    static generateToken(): string {
        return generateSecureToken(32);
    }
    
    /**
     * Set CSRF token cookie with timestamp
     */
    static setTokenCookie(response: Response, token: string, maxAge: number = 7200): void {
        const tokenData: CSRFTokenData = {
            token,
            timestamp: Date.now()
        };
        
        const cookie = createSecureCookie({
            name: this.COOKIE_NAME,
            value: JSON.stringify(tokenData),
            sameSite: 'Strict',
            maxAge
        });
        response.headers.append('Set-Cookie', cookie);
    }
    
    /**
     * Extract CSRF token from cookies with validation
     */
    static getTokenFromCookie(request: Request): string | null {
        const cookieHeader = request.headers.get('Cookie');
        if (!cookieHeader) return null;
        
        const cookies = parseCookies(cookieHeader);
        const cookieValue = cookies[this.COOKIE_NAME];
        
        if (!cookieValue) return null;
        
        try {
            const tokenData: CSRFTokenData = JSON.parse(cookieValue);
            
            const now = Date.now();
            const tokenAge = now - tokenData.timestamp;
            
            if (tokenAge > this.defaults.tokenTTL) {
                logger.debug('CSRF token expired', {
                    tokenAge,
                    maxAge: this.defaults.tokenTTL
                });
                return null;
            }
            return tokenData.token;
        } catch (error) {
            // Handle legacy tokens (plain string) for backward compatibility
            if (typeof cookieValue === 'string' && cookieValue.length > 0) {
                logger.debug('Using legacy CSRF token format');
                return cookieValue;
            }
            
            logger.warn('Invalid CSRF token format', error);
            return null;
        }
    }
    
    /**
     * Extract CSRF token from request header
     */
    static getTokenFromHeader(request: Request): string | null {
        return request.headers.get(this.HEADER_NAME);
    }
    
    /**
     * Validate CSRF token (double-submit cookie pattern)
     */
    static validateToken(request: Request): boolean {
        const method = request.method.toUpperCase();
        
        // Skip validation for safe methods
        if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
            return true;
        }

        // CSRF is only relevant for cookie-based (ambient) authentication.
        // If the client provides an explicit credential header, skip CSRF.
        const authHeader = request.headers.get('Authorization');
        if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
            return true;
        }
        const apiKeyHeader = request.headers.get('X-API-Key');
        if (apiKeyHeader) {
            return true;
        }

        // Skip for WebSocket upgrades
        const upgradeHeader = request.headers.get('upgrade');
        if (upgradeHeader?.toLowerCase() === 'websocket') {
            return true;
        }
        
        return this.validateDoubleSubmitToken(request);
    }

    static validateDoubleSubmitToken(request: Request): boolean {
        const method = request.method.toUpperCase();
        const cookieToken = this.getTokenFromCookie(request);
        const headerToken = this.getTokenFromHeader(request);
        
        // Both tokens must exist and match
        if (!cookieToken || !headerToken) {
            logger.warn('CSRF validation failed: missing token', {
                hasCookie: !!cookieToken,
                hasHeader: !!headerToken,
                method,
                path: new URL(request.url).pathname,
                userAgent: request.headers.get('User-Agent')?.substring(0, 100),
                origin: request.headers.get('Origin'),
                referer: request.headers.get('Referer')
            });
            captureSecurityEvent('csrf_violation', {
                reason: 'missing_token',
                hasCookie: !!cookieToken,
                hasHeader: !!headerToken,
                method,
                path: new URL(request.url).pathname,
                origin: request.headers.get('Origin'),
                referer: request.headers.get('Referer'),
            });
            return false;
        }
        
        if (cookieToken !== headerToken) {
            logger.warn('CSRF validation failed: token mismatch', {
                method,
                path: new URL(request.url).pathname,
                userAgent: request.headers.get('User-Agent')?.substring(0, 100),
                origin: request.headers.get('Origin'),
                referer: request.headers.get('Referer'),
                cookieTokenPrefix: cookieToken.substring(0, 8),
                headerTokenPrefix: headerToken.substring(0, 8)
            });
            captureSecurityEvent('csrf_violation', {
                reason: 'token_mismatch',
                method,
                path: new URL(request.url).pathname,
                origin: request.headers.get('Origin'),
                referer: request.headers.get('Referer'),
                cookieTokenPrefix: cookieToken.substring(0, 8),
                headerTokenPrefix: headerToken.substring(0, 8)
            });
            return false;
        }
        
        logger.debug('CSRF validation successful', {
            method,
            path: new URL(request.url).pathname
        });
        
        return true;
    }
    
    /**
     * Middleware to enforce CSRF protection with configuration
     */
    static async enforce(
        request: Request, 
        response?: Response
    ): Promise<void> {
        // Generate and set token for GET requests (to establish cookie)
        if (request.method === 'GET' && response) {
            const existingToken = this.getTokenFromCookie(request);
            if (!existingToken) {
                const newToken = this.generateToken();
                const maxAge = Math.floor(this.defaults.tokenTTL / 1000);
                this.setTokenCookie(response, newToken, maxAge);
                logger.debug('New CSRF token generated for GET request');
            }
            return;
        }
        
        // Validate token for state-changing requests
        if (!this.validateToken(request)) {
            throw new SecurityError(
                SecurityErrorType.CSRF_VIOLATION,
                'CSRF token validation failed',
                403
            );
        }
    }
    
    /**
     * Get or generate CSRF token for a request with proper rotation
     */
    static getOrGenerateToken(
        request: Request, 
        forceNew: boolean = false
    ): string {
        if (forceNew) {
            const newToken = this.generateToken();
            logger.debug('Forced generation of new CSRF token');
            return newToken;
        }
        
        const existingToken = this.getTokenFromCookie(request);
        if (existingToken) {
            logger.debug('Using existing valid CSRF token');
            return existingToken;
        }
        
        const newToken = this.generateToken();
        logger.debug('Generated new CSRF token due to missing/expired token');
        return newToken;
    }
    
    /**
     * Rotate CSRF token (generate new token and invalidate old one)
     */
    static rotateToken(response: Response): string {
        const newToken = this.generateToken();
        const maxAge = Math.floor(this.defaults.tokenTTL / 1000);
        
        this.setTokenCookie(response, newToken, maxAge);
        logger.info('CSRF token rotated');
        
        return newToken;
    }
    
    /**
     * Clear CSRF token cookie
     */
    static clearTokenCookie(response: Response): void {
        const cookie = createSecureCookie({
            name: this.COOKIE_NAME,
            value: '',
            sameSite: 'Strict',
            maxAge: 0
        });
        response.headers.append('Set-Cookie', cookie);
    }
}