fix: Complete CSRF protection implementation
Closes three CSRF security gaps identified in code review: 1. Added X-CSRF-Token and X-Workspace-Id to CORS allowed headers - Updated apps/api/src/main.ts to accept CSRF token headers 2. Integrated CSRF token handling in web client - Added fetchCsrfToken() to fetch token from API - Store token in memory (not localStorage for security) - Automatically include X-CSRF-Token in POST/PUT/PATCH/DELETE - Implement automatic token refresh on 403 CSRF errors - Added comprehensive test coverage for CSRF functionality 3. Applied CSRF Guard globally - Added CsrfGuard as APP_GUARD in app.module.ts - Verified @SkipCsrf() decorator works for exempted endpoints All tests passing. CSRF protection now enforced application-wide. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,12 @@
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
||||
|
||||
/**
|
||||
* In-memory CSRF token storage
|
||||
* Using module-level variable instead of localStorage for security
|
||||
*/
|
||||
let csrfToken: string | undefined;
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
@@ -27,6 +33,60 @@ export interface ApiResponse<T> {
|
||||
*/
|
||||
export interface ApiRequestOptions extends RequestInit {
|
||||
workspaceId?: string;
|
||||
_isRetry?: boolean; // Internal flag to prevent infinite retry loops
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CSRF token from the API
|
||||
* Token is stored in an httpOnly cookie and returned in response body
|
||||
*/
|
||||
export async function fetchCsrfToken(): Promise<string> {
|
||||
const url = `${API_BASE_URL}/api/v1/csrf/token`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(
|
||||
(): ApiError => ({
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: response.statusText || "Failed to fetch CSRF token",
|
||||
})
|
||||
);
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token: string };
|
||||
csrfToken = data.token;
|
||||
return data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current CSRF token from memory
|
||||
*/
|
||||
export function getCsrfToken(): string | undefined {
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the CSRF token from memory
|
||||
* Useful for testing or after logout
|
||||
*/
|
||||
export function clearCsrfToken(): void {
|
||||
csrfToken = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure CSRF token is available for state-changing requests
|
||||
*/
|
||||
async function ensureCsrfToken(): Promise<string> {
|
||||
if (!csrfToken) {
|
||||
return fetchCsrfToken();
|
||||
}
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +94,7 @@ export interface ApiRequestOptions extends RequestInit {
|
||||
*/
|
||||
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const { workspaceId, ...fetchOptions } = options;
|
||||
const { workspaceId, _isRetry, ...fetchOptions } = options;
|
||||
|
||||
// Build headers with workspace ID if provided
|
||||
const baseHeaders = (fetchOptions.headers as Record<string, string> | undefined) ?? {};
|
||||
@@ -48,6 +108,15 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
headers["X-Workspace-Id"] = workspaceId;
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)
|
||||
const method = (fetchOptions.method ?? "GET").toUpperCase();
|
||||
const isStateChanging = ["POST", "PUT", "PATCH", "DELETE"].includes(method);
|
||||
|
||||
if (isStateChanging) {
|
||||
const token = await ensureCsrfToken();
|
||||
headers["X-CSRF-Token"] = token;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
@@ -62,6 +131,19 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
})
|
||||
);
|
||||
|
||||
// Handle CSRF token mismatch - refresh token and retry once
|
||||
if (
|
||||
response.status === 403 &&
|
||||
(error.code === "CSRF_ERROR" || error.message.includes("CSRF")) &&
|
||||
!_isRetry
|
||||
) {
|
||||
// Refresh CSRF token
|
||||
await fetchCsrfToken();
|
||||
|
||||
// Retry the request with new token
|
||||
return apiRequest<T>(endpoint, { ...options, _isRetry: true });
|
||||
}
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user