Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions backend/src/__tests__/integration/cors.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
17 changes: 16 additions & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,25 @@ app.use(
}),
);

const allowedOrigins = new Set<string>([
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"],
}),
);

Expand Down
Loading