@csrf-armor/express
Version:
Express.js adapter for CSRF Armor - Advanced CSRF protection for Express.js applications
235 lines (219 loc) • 7.27 kB
text/typescript
import type {
CookieOptions,
CsrfAdapter,
CsrfRequest,
CsrfResponse,
RequiredCsrfConfig,
} from '@csrf-armor/core';
import type express from 'express';
/**
* Express.js adapter for CSRF protection.
*
* This adapter implements the CsrfAdapter interface to provide CSRF protection
* for Express.js applications. It handles request/response transformation,
* token extraction from various sources, and cookie/header management.
*
* @example
* ```typescript
* import { CsrfProtection } from '@csrf-armor/core';
* import { ExpressAdapter } from '@csrf-armor/express';
*
* const csrf = new CsrfProtection(new ExpressAdapter(), {
* secret: 'your-secret-key',
* strategy: 'signed-double-submit'
* });
* ```
*/
export class ExpressAdapter
implements CsrfAdapter<express.Request, express.Response>
{
/**
* Extracts CSRF-relevant data from an Express.js request.
*
* Converts an Express Request object into a standardized CsrfRequest format
* that can be processed by the core CSRF protection logic. Handles header
* normalization, cookie extraction, and body access.
*
* @param req - Express.js request object
* @returns Normalized CSRF request object
*
* @example
* ```typescript
* const adapter = new ExpressAdapter();
* const csrfRequest = adapter.extractRequest(req);
* // csrfRequest contains normalized headers, cookies, and body
* ```
*/
extractRequest(req: express.Request): CsrfRequest {
// Create a Map for headers with proper type handling
const headers = new Map<string, string>();
// Safely process headers, handling string, string[], and undefined values
if (req.headers) {
Object.entries(req.headers).forEach(([key, value]) => {
if (Array.isArray(value)) {
// Join array values into a single string
headers.set(key.toLowerCase(), value.join(', '));
} else if (value !== undefined) {
// Set string values directly
headers.set(key.toLowerCase(), value);
}
// Skip undefined values
});
}
return {
method: req.method,
url: req.url,
headers,
cookies: new Map(
Object.entries(req.cookies ?? {}).map(([key, value]) => [key.toLowerCase(), String(value)])
),
body: req.body,
};
}
/**
* Sets a cookie on the Express.js response with CSRF protection options.
*
* Converts CSRF armor cookie options to Express.js cookie format,
* including proper maxAge conversion (seconds to milliseconds).
*
* @param res - Express.js response object
* @param name - Cookie name
* @param value - Cookie value
* @param options - CSRF cookie options
*
* @private
*/
private setCookie(
res: express.Response,
name: string,
value: string,
options?: CookieOptions
): void {
res.cookie(name, value, {
secure: options?.secure,
httpOnly: options?.httpOnly,
sameSite: options?.sameSite,
path: options?.path,
domain: options?.domain,
maxAge: options?.maxAge ? options.maxAge * 1000 : undefined, // Express expects milliseconds
});
}
/**
* Sets HTTP headers on the Express.js response.
*
* Handles both Map and object-based header formats from the CSRF response.
*
* @param res - Express.js response object
* @param headers - Headers to set (Map or object format)
*
* @private
*/
private setHeaders(
res: express.Response,
headers: CsrfResponse['headers']
): void {
const entries =
headers instanceof Map ? headers : Object.entries(headers || {});
for (const [key, value] of entries) {
res.setHeader(key, value);
}
}
/**
* Applies CSRF protection data to an Express.js response.
*
* Sets headers and cookies on the Express response based on the CSRF
* protection results. This includes CSRF tokens, strategy hints, and
* security cookies.
*
* @param res - Express.js response object to modify
* @param csrfResponse - CSRF response data containing headers and cookies
* @returns The modified Express.js response object
*
* @example
* ```typescript
* const adapter = new ExpressAdapter();
* const modifiedResponse = adapter.applyResponse(res, {
* headers: new Map([['x-csrf-token', 'abc123']]),
* cookies: new Map([['csrf-token', { value: 'def456', options: { httpOnly: true } }]])
* });
* ```
*/
applyResponse(
res: express.Response,
csrfResponse: CsrfResponse
): express.Response {
this.setHeaders(res, csrfResponse.headers);
if (csrfResponse.cookies) {
const entries =
csrfResponse.cookies instanceof Map
? Array.from(csrfResponse.cookies.entries())
: Object.entries(csrfResponse.cookies);
for (const [name, { value, options }] of entries) {
this.setCookie(res, name, value, options);
}
}
return res;
}
/**
* Extracts CSRF token from various locations in an Express.js request.
*
* Attempts to find the CSRF token in the following order:
* 1. HTTP headers (e.g., X-CSRF-Token)
* 2. URL query parameters
* 3. Request body (form data or JSON)
*
* This method handles relative URLs safely by providing a base URL for parsing.
*
* @param request - Normalized CSRF request object
* @param config - CSRF configuration containing token field names
* @returns The extracted token string, or undefined if not found
*
* @example
* ```typescript
* const adapter = new ExpressAdapter();
* const token = await adapter.getTokenFromRequest(csrfRequest, {
* token: { headerName: 'X-CSRF-Token', fieldName: 'csrf_token' }
* });
* // Returns token from header, query param, or body
* ```
*/
async getTokenFromRequest(
request: CsrfRequest,
config: RequiredCsrfConfig
): Promise<string | undefined> {
const headers =
request.headers instanceof Map
? request.headers
: new Map(Object.entries(request.headers));
// Try header first (most common for APIs)
const headerValue = headers.get(config.token.headerName.toLowerCase());
if (headerValue) return headerValue;
// Try cookie
const cookies =
request.cookies instanceof Map
? request.cookies
: new Map(
Object.entries(request.cookies || {}).map(([key, value]) => [key.toLowerCase(), String(value)])
);
const cookieValue = cookies.get(config.cookie.name.toLowerCase());
if (cookieValue) return cookieValue;
// Try query parameter
if (request.url) {
try {
// Express request.url is a relative path, so we need to create a full URL
const url = new URL(request.url, 'http://localhost');
const queryValue = url.searchParams.get(config.token.fieldName);
if (queryValue) return queryValue;
} catch {
// If URL parsing fails, skip query parameter extraction
}
}
// Try form body
if (request.body && typeof request.body === 'object') {
const body = request.body as Record<string, unknown>;
const formValue = body[config.token.fieldName];
if (typeof formValue === 'string') return formValue;
}
return undefined;
}
}