0476315322
* feat(security): implement CSRF protection * chore: clean up CSRF implementation - Remove unused generateCsrfToken export from security.ts - Remove redundant /csrf-token path check (GET already exempt) - Restore defineConfig wrapper in vitest.config.ts for type safety * add K8S note in README, fix broken e2e * feat/upload-bar (#30) * feat/upload-bar: add a upload bar when user upload file, indicate the upload process * feat/save-loading-status: add save status when click back button from editor * fix: address PR review issues in upload and save features - Replace deprecated substr() with substring() in UploadContext - Fix broken error handling that checked stale task status - Fix missing useEffect dependency in UploadStatus - Fix CSS class conflict in progress bar styling - Add error recovery for save state in Editor (reset on failure) - Use .finally() instead of .then() to ensure refresh on upload failure - Fix inconsistent indentation in UploadContext * fix e2e tests --------- Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com> * chore: pre-release v0.2.1-dev * Update backend/src/security.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix filename/math random UUID generation --------- Co-authored-by: AdrianAcala <adrianacala017@gmail.com> Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
/**
|
|
* CSRF Tests - Horizontal Scaling (K8s) Validation
|
|
*
|
|
* PR #20 review concern:
|
|
* "Worried that in memory token store might not work on horizontal scaling"
|
|
*
|
|
* Fix:
|
|
* - CSRF tokens are now stateless and HMAC-signed using a shared `CSRF_SECRET`.
|
|
* - Any pod can validate any token as long as all pods share the same secret.
|
|
*
|
|
* These tests prove:
|
|
* - Tokens validate correctly for the issuing client id
|
|
* - Tokens do NOT validate for a different client id
|
|
* - Tokens expire after 24 hours
|
|
* - Tokens validate across separate module instances (simulated pods)
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
|
|
|
|
const SHARED_SECRET = "test-shared-csrf-secret";
|
|
|
|
beforeAll(() => {
|
|
// Must be shared across instances/pods for horizontal scaling.
|
|
process.env.CSRF_SECRET = SHARED_SECRET;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("CSRF - stateless HMAC tokens", () => {
|
|
it("creates a token in payload.signature format and validates for same client id", async () => {
|
|
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
|
|
|
const clientId = "test-client-1";
|
|
const token = createCsrfToken(clientId);
|
|
|
|
expect(typeof token).toBe("string");
|
|
// base64url(payload).base64url(signature)
|
|
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
|
expect(validateCsrfToken(clientId, token)).toBe(true);
|
|
});
|
|
|
|
it("rejects validation for a different client id (token binding)", async () => {
|
|
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
|
|
|
const token = createCsrfToken("client-a");
|
|
expect(validateCsrfToken("client-b", token)).toBe(false);
|
|
});
|
|
|
|
it("rejects malformed tokens", async () => {
|
|
const { validateCsrfToken } = await import("../security");
|
|
|
|
expect(validateCsrfToken("client", "not-a-token")).toBe(false);
|
|
expect(validateCsrfToken("client", "a.b.c")).toBe(false);
|
|
expect(validateCsrfToken("client", "")).toBe(false);
|
|
});
|
|
|
|
it("revokeCsrfToken is a no-op for stateless tokens (does not break callers)", async () => {
|
|
const { createCsrfToken, validateCsrfToken, revokeCsrfToken } = await import(
|
|
"../security"
|
|
);
|
|
|
|
const clientId = "client-revoke";
|
|
const token = createCsrfToken(clientId);
|
|
|
|
expect(validateCsrfToken(clientId, token)).toBe(true);
|
|
revokeCsrfToken(clientId);
|
|
// Stateless token remains valid until expiry
|
|
expect(validateCsrfToken(clientId, token)).toBe(true);
|
|
});
|
|
|
|
it("expires tokens after 24 hours", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
|
|
|
|
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
|
|
|
const clientId = "client-expiry";
|
|
const token = createCsrfToken(clientId);
|
|
expect(validateCsrfToken(clientId, token)).toBe(true);
|
|
|
|
// 24h + 1ms later
|
|
vi.setSystemTime(new Date("2025-01-02T00:00:00.001Z"));
|
|
expect(validateCsrfToken(clientId, token)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("CSRF - horizontal scaling (simulated pods)", () => {
|
|
it("validates across module instances (pod A issues, pod B validates)", async () => {
|
|
const clientId = "user-123";
|
|
|
|
vi.resetModules();
|
|
const podA = await import("../security");
|
|
const token = podA.createCsrfToken(clientId);
|
|
|
|
// Simulate a different pod (new Node.js process / fresh module state)
|
|
vi.resetModules();
|
|
const podB = await import("../security");
|
|
|
|
expect(podB.validateCsrfToken(clientId, token)).toBe(true);
|
|
});
|
|
|
|
it("has 0% failure rate under round-robin validation across 3 pods", async () => {
|
|
const clientId = "user-round-robin";
|
|
|
|
const pods: Array<{
|
|
createCsrfToken: (clientId: string) => string;
|
|
validateCsrfToken: (clientId: string, token: string) => boolean;
|
|
}> = [];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
vi.resetModules();
|
|
pods.push(await import("../security"));
|
|
}
|
|
|
|
// Token issued on one pod
|
|
const token = pods[0].createCsrfToken(clientId);
|
|
|
|
// Validate on alternating pods (simulates a non-sticky load balancer)
|
|
const attempts = 60;
|
|
let failures = 0;
|
|
|
|
for (let i = 0; i < attempts; i++) {
|
|
const pod = pods[i % pods.length];
|
|
if (!pod.validateCsrfToken(clientId, token)) failures++;
|
|
}
|
|
|
|
expect(failures).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("CSRF - referer origin parsing", () => {
|
|
it("extracts exact origin from a referer URL", async () => {
|
|
const { getOriginFromReferer } = await import("../security");
|
|
|
|
expect(getOriginFromReferer("https://example.com/path?x=1")).toBe(
|
|
"https://example.com"
|
|
);
|
|
expect(getOriginFromReferer("http://localhost:5173/some/page")).toBe(
|
|
"http://localhost:5173"
|
|
);
|
|
});
|
|
|
|
it("does not allow prefix tricks (origin must be parsed)", async () => {
|
|
const { getOriginFromReferer } = await import("../security");
|
|
|
|
expect(
|
|
getOriginFromReferer("https://example.com.evil.com/anything")
|
|
).toBe("https://example.com.evil.com");
|
|
|
|
// `startsWith("https://example.com")` would incorrectly allow this.
|
|
expect(getOriginFromReferer("https://example.com@evil.com/anything")).toBe(
|
|
"https://evil.com"
|
|
);
|
|
});
|
|
|
|
it("returns null for invalid or non-http(s) referers", async () => {
|
|
const { getOriginFromReferer } = await import("../security");
|
|
|
|
expect(getOriginFromReferer("")).toBeNull();
|
|
expect(getOriginFromReferer("not a url")).toBeNull();
|
|
expect(getOriginFromReferer("file:///etc/passwd")).toBeNull();
|
|
expect(getOriginFromReferer(null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
|