From 3068002e488b69e86ed9a71309033d3a355beae1 Mon Sep 17 00:00:00 2001 From: Amalanand Muthukumaran Date: Sat, 25 Jul 2026 14:30:37 -0700 Subject: [PATCH] security: production CORS allowlist with fail-safe denial Ported from the security pack's index.ts change to the post-refactor app.ts (the Express app moved in the integration-test extraction). Adapted-from: https://github.com/Open-Legal-Products/mike/pull/227 (4c44c15, CORS half) --- .../src/__tests__/integration/cors.test.ts | 63 +++++++++++++++++++ backend/src/app.ts | 17 ++++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 backend/src/__tests__/integration/cors.test.ts diff --git a/backend/src/__tests__/integration/cors.test.ts b/backend/src/__tests__/integration/cors.test.ts new file mode 100644 index 000000000..08390e28a --- /dev/null +++ b/backend/src/__tests__/integration/cors.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +// requireAuth reads SUPABASE_URL / SUPABASE_SECRET_KEY from process.env at +// request time (not import time), so setting them here is early enough even +// though imported modules evaluate before this assignment runs. +process.env.SUPABASE_URL = "http://supabase.test.local"; +process.env.SUPABASE_SECRET_KEY = "test-service-key"; + +// Mock the supabase-js client factory so the real requireAuth middleware never +// makes a network call (same shape as health.test.ts). +vi.mock("@supabase/supabase-js", () => ({ + createClient: vi.fn(() => ({ + auth: { + getUser: () => + Promise.resolve({ data: { user: null }, error: null }), + }, + })), +})); + +import { app } from "../../app"; + +const ALLOWED_ORIGIN = process.env.FRONTEND_URL ?? "http://localhost:3000"; + +describe("CORS allowlist", () => { + it("reflects an allowlisted origin with credentials", async () => { + const res = await request(app) + .options("/chat") + .set("Origin", ALLOWED_ORIGIN) + .set("Access-Control-Request-Method", "POST"); + expect(res.headers["access-control-allow-origin"]).toBe( + ALLOWED_ORIGIN, + ); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + it("omits Access-Control-Allow-Origin for a non-allowlisted origin", async () => { + const res = await request(app) + .options("/chat") + .set("Origin", "https://evil.example") + .set("Access-Control-Request-Method", "POST"); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it("does not turn a disallowed origin into a 5xx", async () => { + const res = await request(app) + .options("/chat") + .set("Origin", "https://evil.example") + .set("Access-Control-Request-Method", "POST"); + expect(res.status).toBeLessThan(500); + }); + + it("limits preflight-approved request headers to Authorization and Content-Type", async () => { + const res = await request(app) + .options("/chat") + .set("Origin", ALLOWED_ORIGIN) + .set("Access-Control-Request-Method", "POST") + .set("Access-Control-Request-Headers", "Authorization"); + expect(res.headers["access-control-allow-headers"]).toBe( + "Authorization,Content-Type", + ); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 19cb5c3a0..73b06311e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -111,10 +111,25 @@ app.use( }), ); +const allowedOrigins = new Set([ + process.env.FRONTEND_URL ?? "http://localhost:3000", +]); + app.use( cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + origin: (origin, callback) => { + // Allow server-to-server requests (no Origin header) and any + // explicitly listed origin. A disallowed origin resolves to `false` + // (cors omits the Access-Control-Allow-Origin header and the browser + // blocks the response) rather than calling back with an Error — + // throwing here would propagate to Express's default handler and turn + // every disallowed cross-origin request, including preflight, into an + // HTTP 500. + callback(null, !origin || allowedOrigins.has(origin)); + }, credentials: true, + allowedHeaders: ["Authorization", "Content-Type"], + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], }), );