feat(security): implement CSRF protection

This commit is contained in:
AdrianAcala
2025-12-21 02:47:14 -08:00
parent e75b727a5a
commit 8a78b2bb2e
25 changed files with 1157 additions and 580 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
// Locally, you may need to start them manually or use npm run dev
webServer: process.env.CI ? [
{
command: "cd ../backend && DATABASE_URL=file:./prisma/dev.db npm run dev",
command: "cd ../backend && DATABASE_URL=file:./dev.db npm run dev",
url: "http://localhost:8000/health",
reuseExistingServer: false,
timeout: 120000,
+85
View File
@@ -7,6 +7,91 @@ export const api = axios.create({
baseURL: API_URL,
});
// CSRF Token Management
let csrfToken: string | null = null;
let csrfHeaderName: string = "x-csrf-token";
let csrfTokenPromise: Promise<void> | null = null;
/**
* Fetch a fresh CSRF token from the server
*/
export const fetchCsrfToken = async (): Promise<void> => {
try {
const response = await axios.get<{ token: string; header: string }>(
`${API_URL}/csrf-token`
);
csrfToken = response.data.token;
csrfHeaderName = response.data.header || "x-csrf-token";
} catch (error) {
console.error("Failed to fetch CSRF token:", error);
throw error;
}
};
/**
* Ensure we have a valid CSRF token, fetching one if needed
*/
const ensureCsrfToken = async (): Promise<void> => {
if (csrfToken) return;
// Prevent multiple simultaneous token fetches
if (!csrfTokenPromise) {
csrfTokenPromise = fetchCsrfToken().finally(() => {
csrfTokenPromise = null;
});
}
await csrfTokenPromise;
};
/**
* Clear the cached CSRF token (useful for handling 403 errors)
*/
export const clearCsrfToken = (): void => {
csrfToken = null;
};
// Add request interceptor to include CSRF token
api.interceptors.request.use(
async (config) => {
// Only add CSRF token for state-changing methods
const method = config.method?.toUpperCase();
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
await ensureCsrfToken();
if (csrfToken) {
config.headers[csrfHeaderName] = csrfToken;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Add response interceptor to handle CSRF token errors
api.interceptors.response.use(
(response) => response,
async (error) => {
// If we get a 403 with CSRF error, clear token and retry once
if (
error.response?.status === 403 &&
error.response?.data?.error?.includes("CSRF")
) {
clearCsrfToken();
// Retry the request once with a fresh token
const originalRequest = error.config;
if (!originalRequest._csrfRetry) {
originalRequest._csrfRetry = true;
await fetchCsrfToken();
if (csrfToken) {
originalRequest.headers[csrfHeaderName] = csrfToken;
}
return api(originalRequest);
}
}
return Promise.reject(error);
}
);
const coerceTimestamp = (value: string | number | Date): number => {
if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime();
+9 -8
View File
@@ -1,5 +1,5 @@
import { exportToSvg } from "@excalidraw/excalidraw";
import { API_URL } from "../api";
import { api } from "../api";
export const importDrawings = async (
files: File[],
@@ -50,21 +50,22 @@ export const importDrawings = async (
preview: svg.outerHTML,
};
const res = await fetch(`${API_URL}/drawings`, {
method: "POST",
await api.post("/drawings", payload, {
headers: {
"Content-Type": "application/json",
// Backend uses this header to apply stricter validation for imported files.
"X-Imported-File": "true",
},
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("API Error");
successCount++;
} catch (err: any) {
console.error(`Failed to import ${file.name}:`, err);
failCount++;
errors.push(`${file.name}: ${err.message}`);
const apiMessage =
err?.response?.data?.message ||
err?.response?.data?.error ||
err?.message ||
"API Error";
errors.push(`${file.name}: ${apiMessage}`);
}
})
);