@csrf-armor/core
Version:
Framework-agnostic CSRF protection core functionality
404 lines (402 loc) • 13.3 kB
JavaScript
import { generateNonce, generateSecureSecret, generateSignedToken, parseSignedToken, signUnsignedToken, timingSafeEqual, verifySignedToken } from "./crypto.js";
import { CSRF_STRATEGY_HEADER, CSRF_TOKEN_HEADER, DEFAULT_CONFIG, DEFAULT_NONCE_LENGTH, ORIGIN_CHECK_NONCE_LENGTH, SAFE_METHODS, SERVER_CSRF_COOKIE_SUFFIX } from "./constants.js";
import { validateRequest } from "./validation.js";
//#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 {
config;
adapter;
/**
* 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 headers = processHeaders(request.headers);
const contentType = 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": {
const payload = await parseSignedToken(clientTokenFromRequest, this.config.secret);
if (payload.exp > currentTime + reissueThreshold) return {
clientToken: clientTokenFromRequest,
cookieToken: clientTokenFromRequest,
cookieOptions: {
...this.config.cookie,
httpOnly: false
}
};
break;
}
case "signed-double-submit": {
if (serverCookieTokenFromRequest && clientTokenFromRequest) try {
const verifiedToken = await verifySignedToken(serverCookieTokenFromRequest, this.config.secret);
if (timingSafeEqual(verifiedToken, 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\".");
const signedToken = await signUnsignedToken(unsignedToken, this.config.secret);
return {
clientToken: unsignedToken,
cookieToken: unsignedToken,
serverCookieToken: signedToken,
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.js.map