fix(SEC-WEB-32+34): Add input maxLength limits + API request timeout
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
SEC-WEB-32: Added maxLength to form inputs (names: 100, descriptions: 500, emails: 254) in WorkspaceSettings, TeamSettings, InviteMember components. SEC-WEB-34: Added AbortController timeout (30s default, configurable) to apiRequest and apiPostFormData in API client. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,11 +28,16 @@ export interface ApiResponse<T> {
|
||||
};
|
||||
}
|
||||
|
||||
/** Default timeout for API requests in milliseconds (30 seconds) */
|
||||
export const DEFAULT_API_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Options for API requests with workspace context
|
||||
*/
|
||||
export interface ApiRequestOptions extends RequestInit {
|
||||
workspaceId?: string;
|
||||
/** Request timeout in milliseconds. Defaults to 30000 (30s). Set to 0 to disable. */
|
||||
timeoutMs?: number;
|
||||
_isRetry?: boolean; // Internal flag to prevent infinite retry loops
|
||||
}
|
||||
|
||||
@@ -94,60 +99,91 @@ async function ensureCsrfToken(): Promise<string> {
|
||||
*/
|
||||
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const { workspaceId, _isRetry, ...fetchOptions } = options;
|
||||
const { workspaceId, timeoutMs, _isRetry, ...fetchOptions } = options;
|
||||
|
||||
// Build headers with workspace ID if provided
|
||||
const baseHeaders = (fetchOptions.headers as Record<string, string> | undefined) ?? {};
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...baseHeaders,
|
||||
};
|
||||
// Set up abort controller for timeout
|
||||
const timeout = timeoutMs ?? DEFAULT_API_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Add workspace ID header if provided (recommended over query string)
|
||||
if (workspaceId) {
|
||||
headers["X-Workspace-Id"] = workspaceId;
|
||||
if (timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Merge with any caller-provided signal
|
||||
const callerSignal = fetchOptions.signal;
|
||||
if (callerSignal) {
|
||||
callerSignal.addEventListener("abort", () => {
|
||||
controller.abort();
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
credentials: "include", // Include cookies for session
|
||||
});
|
||||
try {
|
||||
// Build headers with workspace ID if provided
|
||||
const baseHeaders = (fetchOptions.headers as Record<string, string> | undefined) ?? {};
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...baseHeaders,
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(
|
||||
(): ApiError => ({
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: response.statusText || "An unknown error occurred",
|
||||
})
|
||||
);
|
||||
|
||||
// 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 });
|
||||
// Add workspace ID header if provided (recommended over query string)
|
||||
if (workspaceId) {
|
||||
headers["X-Workspace-Id"] = workspaceId;
|
||||
}
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
// 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);
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
if (isStateChanging) {
|
||||
const token = await ensureCsrfToken();
|
||||
headers["X-CSRF-Token"] = token;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
credentials: "include", // Include cookies for session
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(
|
||||
(): ApiError => ({
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: response.statusText || "An unknown error occurred",
|
||||
})
|
||||
);
|
||||
|
||||
// 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 await apiRequest<T>(endpoint, { ...options, _isRetry: true });
|
||||
}
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return await (response.json() as Promise<T>);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
throw new Error(`Request to ${endpoint} timed out after ${String(timeout)}ms`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,49 +258,73 @@ export async function apiDelete<T>(endpoint: string, workspaceId?: string): Prom
|
||||
export async function apiPostFormData<T>(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
workspaceId?: string
|
||||
workspaceId?: string,
|
||||
timeoutMs?: number
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Add workspace ID header if provided
|
||||
if (workspaceId) {
|
||||
headers["X-Workspace-Id"] = workspaceId;
|
||||
// Set up abort controller for timeout
|
||||
const timeout = timeoutMs ?? DEFAULT_API_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing request
|
||||
const token = await ensureCsrfToken();
|
||||
headers["X-CSRF-Token"] = token;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(
|
||||
(): ApiError => ({
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: response.statusText || "An unknown error occurred",
|
||||
})
|
||||
);
|
||||
|
||||
// Handle CSRF token mismatch - refresh token and retry once
|
||||
if (
|
||||
response.status === 403 &&
|
||||
(error.code === "CSRF_ERROR" || error.message.includes("CSRF"))
|
||||
) {
|
||||
// Refresh CSRF token
|
||||
await fetchCsrfToken();
|
||||
|
||||
// Retry the request with new token (recursive call)
|
||||
return apiPostFormData<T>(endpoint, formData, workspaceId);
|
||||
try {
|
||||
// Add workspace ID header if provided
|
||||
if (workspaceId) {
|
||||
headers["X-Workspace-Id"] = workspaceId;
|
||||
}
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
// Add CSRF token for state-changing request
|
||||
const token = await ensureCsrfToken();
|
||||
headers["X-CSRF-Token"] = token;
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(
|
||||
(): ApiError => ({
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: response.statusText || "An unknown error occurred",
|
||||
})
|
||||
);
|
||||
|
||||
// Handle CSRF token mismatch - refresh token and retry once
|
||||
if (
|
||||
response.status === 403 &&
|
||||
(error.code === "CSRF_ERROR" || error.message.includes("CSRF"))
|
||||
) {
|
||||
// Refresh CSRF token
|
||||
await fetchCsrfToken();
|
||||
|
||||
// Retry the request with new token (recursive call)
|
||||
return await apiPostFormData<T>(endpoint, formData, workspaceId, timeoutMs);
|
||||
}
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return await (response.json() as Promise<T>);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
throw new Error(`Request to ${endpoint} timed out after ${String(timeout)}ms`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user