diff --git a/src/api/routes.ts b/src/api/routes.ts index aa475e850..72dce86c0 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -572,6 +572,10 @@ export function createApp() { c.header("Access-Control-Allow-Headers", "authorization, content-type"); c.header("Access-Control-Allow-Methods", "GET, OPTIONS"); c.header("Access-Control-Max-Age", "600"); + // The response varies by Origin (this branch fires only when the request carried one), so a shared cache + // must not serve this Origin-specific response to a no-Origin request or vice versa — append Vary the same + // way the credentialed branch does at the else-arm below (#9712). + c.header("Vary", "Origin", { append: true }); } else { const allowedOrigin = allowedCorsOrigin(c.env, origin); if (allowedOrigin) { diff --git a/test/unit/routes-cors.test.ts b/test/unit/routes-cors.test.ts index c56d43daf..6e20e70d8 100644 --- a/test/unit/routes-cors.test.ts +++ b/test/unit/routes-cors.test.ts @@ -70,3 +70,43 @@ describe("CORS: everything else stays on the strict, credentialed allowlist (#op expect(res.headers.get("access-control-allow-origin")).toBeNull(); }); }); + +describe("CORS: the public no-credential branch varies by Origin (#9712)", () => { + it("GET /health with an Origin returns Access-Control-Allow-Origin: * AND Vary: Origin", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/health", { headers: { origin: PREVIEW_ORIGIN } }, env); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get("vary")).toContain("Origin"); + }); + + it("GET /v1/public/stats and the per-repo badge route also carry Vary: Origin on the open branch", async () => { + const app = createApp(); + const env = createTestEnv(); + env.LOOPOVER_PUBLIC_STATS = "true"; + const stats = await app.request("/v1/public/stats", { headers: { origin: "https://random-preview.pages.dev" } }, env); + expect(stats.headers.get("access-control-allow-origin")).toBe("*"); + expect(stats.headers.get("vary")).toContain("Origin"); + + const badge = await app.request("/v1/public/github/repos/acme/widgets/stats", { headers: { origin: PREVIEW_ORIGIN } }, env); + expect(badge.headers.get("access-control-allow-origin")).toBe("*"); + expect(badge.headers.get("vary")).toContain("Origin"); + }); + + it("GET /health with NO Origin sets no Access-Control-Allow-Origin — proving the response genuinely varies", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/health", {}, env); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("REGRESSION: the credentialed branch still returns exactly one Vary: Origin plus credentials", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/app/kill-switch", { headers: { origin: "https://loopover.ai", authorization: `Bearer ${env.LOOPOVER_API_TOKEN}` } }, env); + expect(res.headers.get("access-control-allow-credentials")).toBe("true"); + // Exactly one Origin token — appended, not duplicated or overwritten. + expect((res.headers.get("vary") ?? "").split(",").map((v) => v.trim()).filter((v) => v === "Origin")).toEqual(["Origin"]); + }); +});