UNPKG

@csrf-armor/core

Version:

Framework-agnostic CSRF protection core functionality

396 lines (394 loc) 13.1 kB
import { generateNonce, generateSecureSecret, generateSignedToken, parseSignedToken, signUnsignedToken, timingSafeEqual, verifySignedToken } from "./crypto.mjs"; import { CSRF_STRATEGY_HEADER, CSRF_TOKEN_HEADER, DEFAULT_CONFIG, DEFAULT_NONCE_LENGTH, ORIGIN_CHECK_NONCE_LENGTH, SAFE_METHODS, SERVER_CSRF_COOKIE_SUFFIX } from "./constants.mjs"; import { validateRequest } from "./validation.mjs"; //#region src/csrf.ts /** * Extracts the pathname from a URL string for path-based exclusion matching. * * Handles both absolute URLs and relative paths safely. If URL parsing fails, * falls back to manual parsing by finding the query string delimiter. * * @param url - URL string to extract pathname from * @returns The pathname portion of the URL * * @internal */ function extractPathname(url) { try { return new URL(url).pathname; } catch { const questionMarkIndex = url.indexOf("?"); if (questionMarkIndex !== -1) return url.substring(0, questionMarkIndex); return url; } } /** * Normalizes request headers to a consistent Map format. * * Converts various header formats (Map, Headers object, plain object) into * a standardized Map<string, string> for consistent processing throughout * the CSRF protection system. * * @param rawHeaders - Headers in various formats from the request * @returns Normalized headers as a Map * * @internal */ function processHeaders(rawHeaders) { if (rawHeaders instanceof Map) return rawHeaders; return new Map(Object.entries(rawHeaders)); } /** * Merges user configuration with default CSRF configuration values. * * Creates a complete configuration object by combining user-provided options * with secure defaults. Ensures all required fields are present and properly * typed for the CSRF protection system. * * @param defaultConfig - Default configuration values * @param userConfig - User-provided configuration overrides * @returns Complete CSRF configuration with all required fields * * @internal */ function mergeConfig(defaultConfig, userConfig) { const merged = { ...defaultConfig, ...userConfig, cookie: { ...defaultConfig.cookie, ...userConfig?.cookie }, token: { ...defaultConfig.token, ...userConfig?.token } }; const config = { strategy: userConfig?.strategy ?? defaultConfig.strategy ?? "hybrid", secret: userConfig?.secret ?? defaultConfig.secret ?? generateSecureSecret(), token: { expiry: merged.token?.expiry ?? 3600, headerName: merged.token?.headerName ?? "X-CSRF-Token", fieldName: merged.token?.fieldName ?? "csrf_token", reissueThreshold: merged.token?.reissueThreshold ?? 300 }, cookie: { name: merged.cookie?.name ?? "csrf-token", secure: merged.cookie?.secure ?? true, httpOnly: merged.cookie?.httpOnly ?? false, sameSite: merged.cookie?.sameSite ?? "lax", path: merged.cookie?.path ?? "/" }, allowedOrigins: merged.allowedOrigins ?? [], excludePaths: merged.excludePaths ?? [], skipContentTypes: merged.skipContentTypes ?? [] }; if (merged.cookie?.domain) config.cookie.domain = merged.cookie.domain; if (merged.cookie?.maxAge) config.cookie.maxAge = merged.cookie.maxAge; return config; } var CsrfProtection = class { /** * Creates a new CSRF protection instance. * * @param adapter - Framework-specific adapter for request/response handling * @param userConfig - Optional configuration overrides */ constructor(adapter, userConfig) { this.adapter = adapter; this.config = mergeConfig(DEFAULT_CONFIG, userConfig); } /** * Checks if a request should be excluded from CSRF protection. * * @param request - The CSRF request to check * @returns true if the request should be skipped * @internal */ shouldSkipProtection(request) { const pathname = extractPathname(request.url); if (this.config.excludePaths.some((path) => pathname.startsWith(path))) return true; const contentType = processHeaders(request.headers).get("content-type") ?? ""; return this.config.skipContentTypes.some((type) => contentType.includes(type)); } /** * Attempts to reuse existing CSRF tokens if they are still valid. * * @param request - The CSRF request containing potential existing tokens * @returns Token data if reuse is possible, null otherwise * @internal */ async attemptTokenReuse(request) { if (!SAFE_METHODS.includes(request.method)) return null; const cookies = request.cookies instanceof Map ? request.cookies : new Map(Object.entries(request.cookies)); const clientTokenFromRequest = cookies.get(this.config.cookie.name); const serverCookieTokenFromRequest = cookies.get(this.config.cookie.name + SERVER_CSRF_COOKIE_SUFFIX); if (!clientTokenFromRequest) return null; const currentTime = Math.floor(Date.now() / 1e3); const reissueThreshold = this.config.token.reissueThreshold; try { switch (this.config.strategy) { case "signed-token": case "hybrid": if ((await parseSignedToken(clientTokenFromRequest, this.config.secret)).exp > currentTime + reissueThreshold) return { clientToken: clientTokenFromRequest, cookieToken: clientTokenFromRequest, cookieOptions: { ...this.config.cookie, httpOnly: false } }; break; case "signed-double-submit": if (serverCookieTokenFromRequest && clientTokenFromRequest) try { if (timingSafeEqual(await verifySignedToken(serverCookieTokenFromRequest, this.config.secret), clientTokenFromRequest)) return { clientToken: clientTokenFromRequest, cookieToken: clientTokenFromRequest, serverCookieToken: serverCookieTokenFromRequest, cookieOptions: { ...this.config.cookie, httpOnly: false } }; } catch {} break; } } catch (error) { return null; } return null; } /** * Builds the CSRF response with headers and cookies. * * @param tokenData - The token data to include in the response * @returns The CSRF response object * @internal */ buildCsrfResponse(tokenData) { const cookies = new Map([[this.config.cookie.name, { value: tokenData.cookieToken, options: tokenData.cookieOptions }]]); if (tokenData.serverCookieToken) cookies.set(`${this.config.cookie.name}-server`, { value: tokenData.serverCookieToken, options: { ...tokenData.cookieOptions, httpOnly: true } }); return { headers: new Map([[CSRF_TOKEN_HEADER, tokenData.clientToken], [CSRF_STRATEGY_HEADER, this.config.strategy]]), cookies }; } /** * Protects a request/response pair against CSRF attacks. * * This is the main method that applies CSRF protection to incoming requests. * It handles both token generation for safe methods (GET, HEAD, OPTIONS) and * token validation for state-changing methods (POST, PUT, DELETE, etc.). * * The method: * 1. Extracts request data using the framework adapter * 2. Checks if the request should be excluded or skipped * 3. For safe methods: generates and sets new CSRF tokens * 4. For unsafe methods: validates existing tokens using the configured strategy * 5. Applies response data (headers, cookies) using the adapter * * @param request - Framework-specific request object * @param response - Framework-specific response object * @returns Promise resolving to protection result with success status and modified response * * @example * ```typescript * // Basic usage in middleware * const result = await csrf.protect(req, res); * if (!result.success) { * return res.status(403).json({ * error: 'CSRF validation failed', * reason: result.reason * }); * } * * // Token is available for safe methods * if (result.token) { * console.log('Generated CSRF token:', result.token); * } * * // Continue with the modified response * return result.response; * ``` * * @example * ```typescript * // Error handling with specific reasons * const result = await csrf.protect(req, res); * if (!result.success) { * switch (result.reason) { * case 'Invalid token': * return res.status(403).json({ error: 'CSRF token is invalid' }); * case 'Token expired': * return res.status(403).json({ error: 'CSRF token has expired' }); * case 'Origin mismatch': * return res.status(403).json({ error: 'Request origin not allowed' }); * default: * return res.status(403).json({ error: 'CSRF validation failed' }); * } * } * ``` */ async protect(request, response) { const csrfRequest = this.adapter.extractRequest(request); if (this.shouldSkipProtection(csrfRequest)) return { success: true, response }; let tokenData = await this.attemptTokenReuse(csrfRequest); tokenData ??= await this.generateTokensForStrategy(); const csrfResponse = this.buildCsrfResponse(tokenData); const modifiedResponse = this.adapter.applyResponse(response, csrfResponse); if (SAFE_METHODS.includes(csrfRequest.method)) return { success: true, response: modifiedResponse, token: tokenData.clientToken }; const validationResult = await validateRequest(csrfRequest, this.config, this.adapter.getTokenFromRequest); if (!validationResult.isValid) return { success: false, response: modifiedResponse, reason: validationResult.reason ?? "CSRF Validation failed" }; return { success: true, response: modifiedResponse, token: tokenData.clientToken }; } async generateTokensForStrategy() { const baseOptions = this.config.cookie; switch (this.config.strategy) { case "double-submit": { const token = generateNonce(DEFAULT_NONCE_LENGTH); if (!token) throw new Error("CSRF Error: Failed to generate nonce for strategy \"double-submit\"."); return { clientToken: token, cookieToken: token, cookieOptions: { ...baseOptions, httpOnly: false } }; } case "signed-double-submit": { const unsignedToken = generateNonce(DEFAULT_NONCE_LENGTH); if (!unsignedToken) throw new Error("CSRF Error: Failed to generate nonce for strategy \"signed-double-submit\"."); return { clientToken: unsignedToken, cookieToken: unsignedToken, serverCookieToken: await signUnsignedToken(unsignedToken, this.config.secret), cookieOptions: { ...baseOptions, httpOnly: false } }; } case "signed-token": case "hybrid": { const signedToken = await generateSignedToken(this.config.secret, this.config.token.expiry); return { clientToken: signedToken, cookieToken: signedToken, cookieOptions: { ...baseOptions, httpOnly: false } }; } case "origin-check": { const nonce = generateNonce(ORIGIN_CHECK_NONCE_LENGTH); if (!nonce) throw new Error("CSRF Error: Failed to generate nonce for strategy \"origin-check\"."); return { clientToken: nonce, cookieToken: nonce, cookieOptions: { ...baseOptions, httpOnly: false } }; } default: throw new Error(`Unknown CSRF strategy: ${this.config.strategy}`); } } }; /** * Factory function to create a CSRF protection instance. * * Convenient alternative to using the CsrfProtection constructor directly. * This function is the recommended way to create CSRF protection instances * as it provides better type inference and a cleaner API. * * @public * @template TRequest - Framework-specific request type * @template TResponse - Framework-specific response type * @param adapter - Framework adapter implementing CsrfAdapter interface * @param config - Optional CSRF configuration (uses secure defaults if not provided) * @returns Configured CSRF protection instance ready for use * * @example * ```typescript * import { createCsrfProtection } from '@csrf-armor/core'; * import { ExpressAdapter } from '@csrf-armor/express'; * * // Basic setup with defaults * const csrf = createCsrfProtection(new ExpressAdapter()); * * // Custom configuration * const csrf = createCsrfProtection(new ExpressAdapter(), { * strategy: 'signed-double-submit', * secret: process.env.CSRF_SECRET, * token: { * expiry: 7200, // 2 hours * fieldName: 'authenticity_token' * }, * excludePaths: ['/api/public'], * allowedOrigins: ['https://yourdomain.com'] * }); * * // Use in middleware * app.use(async (req, res, next) => { * const result = await csrf.protect(req, res); * if (result.success) { * next(); * } else { * res.status(403).json({ error: result.reason }); * } * }); * ``` * * @example * ```typescript * // Framework-specific usage * * // Express.js * import { ExpressAdapter } from '@csrf-armor/express'; * const expressCsrf = createCsrfProtection(new ExpressAdapter(), config); * * // Next.js * import { NextjsAdapter } from '@csrf-armor/nextjs'; * const nextCsrf = createCsrfProtection(new NextjsAdapter(), config); * * // Custom framework * class MyAdapter implements CsrfAdapter<MyRequest, MyResponse> { * // Implementation... * } * const customCsrf = createCsrfProtection(new MyAdapter(), config); * ``` */ function createCsrfProtection(adapter, config) { return new CsrfProtection(adapter, config); } //#endregion export { CsrfProtection, createCsrfProtection }; //# sourceMappingURL=csrf.mjs.map