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
46 changes: 38 additions & 8 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <alias>-loopover-ui.<sub>.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();
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 10 additions & 6 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
72 changes: 72 additions & 0 deletions test/unit/routes-cors.test.ts
Original file line number Diff line number Diff line change
@@ -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
// <alias>-loopover-ui.<sub>.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();
});
});