From 7f84431029deb7470d551aa20ee36ed9c3aab05f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:36:59 -0700 Subject: [PATCH] fix(api): open the public no-credential routes to any CORS origin /health, /v1/public/stats, and /v1/public/github/repos/:owner/:repo/stats are unauthenticated, cookie-free, aggregate-only endpoints, but sat behind the same strict exact-match CORS allowlist as every authenticated route. Confirmed live: browserless's visual-review capture of PR previews was hitting real CORS errors calling these from a fresh -loopover-ui..workers.dev preview build -- Cloudflare assigns a random hostname per deploy (ui-preview-deploy.yml), so a static allowlist can never enumerate them. Adds a separate, credential-free CORS branch (mirrors the existing handleStats "*" pattern) for exactly these 3 routes, gated by a new isPublicNoCredentialRoute() path check. Deliberately does NOT touch the global allowedCorsOrigin()/Access-Control-Allow-Credentials path for anything else -- this app has real HttpOnly session cookies, so widening the credentialed-CORS allowlist itself to any *.workers.dev/ *.pages.dev origin would let any third party hosted on that same shared platform ride an authenticated user's session cross-origin. Every other route's CORS behavior is unchanged (see the REGRESSION tests in routes-cors.test.ts confirming this). --- src/api/routes.ts | 46 ++++++++++++++++++---- test/integration/api.test.ts | 16 +++++--- test/unit/routes-cors.test.ts | 72 +++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 test/unit/routes-cors.test.ts diff --git a/src/api/routes.ts b/src/api/routes.ts index 6f8a50d636..60356494a9 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -953,15 +953,32 @@ export function createApp() { } /* v8 ignore stop */ app.use("*", async (c, next) => { - const allowedOrigin = allowedCorsOrigin(c.env, c.req.header("origin")); - if (allowedOrigin) { - c.header("Access-Control-Allow-Origin", allowedOrigin); - c.header("Access-Control-Allow-Credentials", "true"); - c.header("Access-Control-Allow-Headers", "authorization, content-type, mcp-session-id, mcp-protocol-version"); - c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); - c.header("Access-Control-Expose-Headers", "x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after"); + const origin = c.req.header("origin"); + if (origin && isPublicNoCredentialRoute(c.req.path)) { + // These specific routes are unauthenticated, cookie-free, aggregate-only public data (health check, + // homepage stats counter, per-repo badge stats) -- open to ANY origin, including a fresh + // -loopover-ui..workers.dev preview build (ui-preview-deploy.yml), which a static + // exact-match allowlist can never enumerate since the hostname is random per deploy. Deliberately + // NEVER sets Access-Control-Allow-Credentials here (mirrors src/review/stats.ts's handleStats, the + // same "*" + no-credentials pattern already used for this exact class of endpoint) -- browsers reject + // a credentialed response against a wildcard origin anyway, but the real safety property is that this + // branch never reaches the credentialed allowlist path below at all, so it can't accidentally grant a + // third-party *.workers.dev/*.pages.dev site cookie-riding access to anything session-gated. + c.header("Access-Control-Allow-Origin", "*"); + c.header("Access-Control-Allow-Headers", "authorization, content-type"); + c.header("Access-Control-Allow-Methods", "GET, OPTIONS"); c.header("Access-Control-Max-Age", "600"); - c.header("Vary", "Origin", { append: true }); + } else { + const allowedOrigin = allowedCorsOrigin(c.env, origin); + if (allowedOrigin) { + c.header("Access-Control-Allow-Origin", allowedOrigin); + c.header("Access-Control-Allow-Credentials", "true"); + c.header("Access-Control-Allow-Headers", "authorization, content-type, mcp-session-id, mcp-protocol-version"); + c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + c.header("Access-Control-Expose-Headers", "x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after"); + c.header("Access-Control-Max-Age", "600"); + c.header("Vary", "Origin", { append: true }); + } } if (c.req.method === "OPTIONS") return c.body(null, 204); return next(); @@ -5950,6 +5967,19 @@ function requiresApiToken(path: string): boolean { return path.startsWith("/v1/"); } +// Unauthenticated, cookie-free, aggregate-only public GET endpoints (health check, homepage stats counter, +// per-repo public stats badge) -- open to any origin via a separate, credential-free CORS branch above. +// Every other route stays on the strict exact-match allowlist + Access-Control-Allow-Credentials, since a +// wildcard origin there would let any third party hosted on the SAME shared platform (a fresh +// *.workers.dev/*.pages.dev preview build isn't the only thing that can land on those suffixes) ride an +// authenticated user's session cookie cross-origin. +function isPublicNoCredentialRoute(path: string): boolean { + if (path === "/health") return true; + if (path === "/v1/public/stats") return true; + if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return true; + return false; +} + const DEFAULT_CORS_ORIGINS = [ "https://loopover.ai", "https://api.loopover.ai", diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 269bcc9c2b..e982f5b802 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -93,16 +93,18 @@ describe("api routes", () => { expect(dynamicPreflight.status).toBe(204); expect(dynamicPreflight.headers.get("access-control-allow-origin")).toBe("https://preview.gittensory.test"); - // REGRESSION: gittensory-ui's dev server (@lovable.dev/vite-tanstack-config) binds 8080, not Vite's 5173 - // default — without this in DEFAULT_CORS_ORIGINS, every local/preview dev server is CORS-blocked from - // /health and the ApiStatusBanner falsely reports "API unreachable" even when the API is healthy. + // /health is one of the unauthenticated, cookie-free public-no-credential routes (#ops-anomaly-preview-cors) + // -- open to ANY origin (never just the DEFAULT_CORS_ORIGINS allowlist), so gittensory-ui's dev server + // (port 8080) and every other local/preview dev server or *.workers.dev/*.pages.dev preview build all get + // through without CORS-blocking the ApiStatusBanner. See routes-cors.test.ts for the dedicated coverage of + // this behavior (including confirming it stays scoped to just this small route set, not every route). const devPortPreflight = await app.request("/health", { method: "OPTIONS", headers: { origin: "http://localhost:8080" } }, env); expect(devPortPreflight.status).toBe(204); - expect(devPortPreflight.headers.get("access-control-allow-origin")).toBe("http://localhost:8080"); + expect(devPortPreflight.headers.get("access-control-allow-origin")).toBe("*"); const devPortLoopbackPreflight = await app.request("/health", { method: "OPTIONS", headers: { origin: "http://127.0.0.1:8080" } }, env); expect(devPortLoopbackPreflight.status).toBe(204); - expect(devPortLoopbackPreflight.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:8080"); + expect(devPortLoopbackPreflight.headers.get("access-control-allow-origin")).toBe("*"); const health = await app.request("/health", {}, env); expect(health.status).toBe(200); @@ -147,9 +149,11 @@ describe("api routes", () => { return Response.json({ full_name: "JSONbored/gittensory", html_url: "https://github.com/JSONbored/gittensory", stargazers_count: 12, forks_count: 3 }); }); + // Also one of the public-no-credential routes (#ops-anomaly-preview-cors) -- open to any origin, not just + // the requesting origin reflected back from the strict allowlist. const response = await app.request("/v1/public/github/repos/JSONbored/gittensory/stats", { headers: { origin: "https://loopover.ai" } }, env); expect(response.status).toBe(200); - expect(response.headers.get("access-control-allow-origin")).toBe("https://loopover.ai"); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); expect(response.headers.get("cache-control")).toContain("max-age=600"); await expect(response.json()).resolves.toMatchObject({ repoFullName: "JSONbored/gittensory", diff --git a/test/unit/routes-cors.test.ts b/test/unit/routes-cors.test.ts new file mode 100644 index 0000000000..c56d43daf6 --- /dev/null +++ b/test/unit/routes-cors.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createTestEnv } from "../helpers/d1"; + +// A fresh Cloudflare Workers preview build (ui-preview-deploy.yml) lands on a random +// -loopover-ui..workers.dev hostname every deploy -- a static exact-match CORS allowlist can +// never enumerate these. Confirmed live: browserless's visual-review capture of a PR preview was hitting +// real CORS errors calling /health and /v1/public/stats from exactly this class of origin. +const PREVIEW_ORIGIN = "https://a1b2c3d4-loopover-ui.some-account.workers.dev"; + +describe("CORS: public no-credential routes open to any origin (#ops-anomaly-preview-cors)", () => { + it("GET /health reflects an arbitrary *.workers.dev origin with NO credentials header", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/health", { headers: { origin: PREVIEW_ORIGIN } }, env); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get("access-control-allow-credentials")).toBeNull(); + }); + + it("GET /v1/public/stats reflects an arbitrary *.pages.dev origin with NO credentials header", async () => { + const app = createApp(); + const env = createTestEnv(); + env.LOOPOVER_PUBLIC_STATS = "true"; + const res = await app.request("/v1/public/stats", { headers: { origin: "https://random-preview.pages.dev" } }, env); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get("access-control-allow-credentials")).toBeNull(); + }); + + it("GET /v1/public/github/repos/:owner/:repo/stats reflects an arbitrary origin with NO credentials header (dynamic path segments)", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/public/github/repos/acme/widgets/stats", { headers: { origin: PREVIEW_ORIGIN } }, env); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get("access-control-allow-credentials")).toBeNull(); + }); + + it("OPTIONS preflight on a public no-credential route also gets the open, no-credentials headers", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/health", { method: "OPTIONS", headers: { origin: PREVIEW_ORIGIN } }, env); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get("access-control-allow-credentials")).toBeNull(); + }); +}); + +describe("CORS: everything else stays on the strict, credentialed allowlist (#ops-anomaly-preview-cors)", () => { + it("REGRESSION: an authenticated route from an unlisted *.workers.dev origin gets NO CORS headers at all (not opened up)", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/app/kill-switch", { headers: { origin: PREVIEW_ORIGIN, authorization: `Bearer ${env.LOOPOVER_API_TOKEN}` } }, env); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + expect(res.headers.get("access-control-allow-credentials")).toBeNull(); + }); + + it("REGRESSION: a genuinely allowlisted origin on a non-public route still gets the credentialed treatment unchanged", 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-origin")).toBe("https://loopover.ai"); + expect(res.headers.get("access-control-allow-credentials")).toBe("true"); + }); + + it("a *.workers.dev origin does NOT get the open treatment on /v1/public/subnet-interface (public, but not in the no-credential allowlist by design -- only the 3 routes that were actually failing)", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/public/subnet-interface", { headers: { origin: PREVIEW_ORIGIN } }, env); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); +});