diff --git a/src/server/cors/origins.ts b/src/server/cors/origins.ts index dfd0b407c5..a354cccb5e 100644 --- a/src/server/cors/origins.ts +++ b/src/server/cors/origins.ts @@ -144,6 +144,26 @@ export function getCorsStatus(): CorsStatus { * compression middleware only appends it conditionally, so shared caches can't * otherwise reliably tell compressed vs uncompressed variants apart. */ +function requestCarriesTokenOrPreflight(request: Request): boolean { + // Preflight (OPTIONS) never carries the Authorization / x-api-key header, so it + // must be allowed through — the actual request that follows is re-evaluated by + // this same check and only gets the permissive Origin if it presents a token. + if (request.method === "OPTIONS") return true; + if ( + request.headers.get("authorization") || + request.headers.get("x-api-key") || + request.headers.get("x-goog-api-key") + ) { + return true; + } + // A dashboard session cookie is a credential too (#5242 browser/Electron + // clients). auth_token is HttpOnly + SameSite, so a cross-site attacker page + // cannot get it auto-attached — only a truly credential-less request (the + // GHSA-7px7 anonymous case on a keyless install) falls through to fail-closed. + const cookie = request.headers.get("cookie"); + return Boolean(cookie && /(?:^|;\s*)auth_token=/.test(cookie)); +} + export function applyCorsHeaders( response: Response, request: Request, @@ -151,7 +171,15 @@ export function applyCorsHeaders( ): void { const requestOrigin = request.headers.get("origin"); let allowed = resolveAllowedOrigin(requestOrigin); - if (allowed === null && relaxForTokenAuth) { + if (allowed === null && relaxForTokenAuth && requestCarriesTokenOrPreflight(request)) { + // GHSA-7px7-29v2-m97p: the permissive Origin echo is only safe on the + // assumption that these routes are token-authenticated (browsers never + // auto-attach Authorization/x-api-key). On a keyless install that assumption + // breaks — an anonymous cross-origin page would be echoed its own Origin and + // could read the response. Only relax for a request that actually carries a + // credential, plus CORS preflights (OPTIONS never carries the header — the + // real request that follows is re-checked), so authenticated browser/Electron + // clients (#5242) keep working while credential-less cross-origin reads do not. allowed = requestOrigin && requestOrigin.length > 0 ? requestOrigin : "*"; } if (allowed !== null) { diff --git a/tests/unit/cors/origins.test.ts b/tests/unit/cors/origins.test.ts index 00950aa097..7d8ecd42ec 100644 --- a/tests/unit/cors/origins.test.ts +++ b/tests/unit/cors/origins.test.ts @@ -129,12 +129,12 @@ describe("cors/origins.applyCorsHeaders", () => { assert.match(res.headers.get("Vary") || "", /Origin/); }); - it("CLIENT_API: echoes arbitrary Origin (+Vary) when no allowlist matches (relaxForTokenAuth)", () => { + it("CLIENT_API: echoes arbitrary Origin (+Vary) for a token-carrying request (relaxForTokenAuth)", () => { // Token-authenticated /v1/* surface (issue #5242): no allowlist, arbitrary // origin → echo it back so browser/Electron renderers can read the body. const res = NextResponse.json({ ok: true }); const req = new Request("https://server.example.com/api/v1/models", { - headers: { Origin: "http://localhost" }, + headers: { Origin: "http://localhost", Authorization: "Bearer omr_test_key" }, }); applyCorsHeaders(res, req, true); assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost"); @@ -143,14 +143,40 @@ describe("cors/origins.applyCorsHeaders", () => { assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null); }); - it("CLIENT_API: returns '*' when no Origin header is present (relaxForTokenAuth)", () => { + it("CLIENT_API: returns '*' when no Origin header is present for a token-carrying request", () => { const res = NextResponse.json({ ok: true }); - const req = new Request("https://server.example.com/api/v1/models"); + const req = new Request("https://server.example.com/api/v1/models", { + headers: { "x-api-key": "omr_test_key" }, + }); applyCorsHeaders(res, req, true); assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*"); assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null); }); + it("CLIENT_API: does NOT echo the Origin for a credential-less cross-origin request (GHSA-7px7)", () => { + // A keyless install serves /v1 anonymously; echoing the Origin to a + // credential-less cross-origin page would let any visited page drive the + // gateway. Only token-carrying requests get the permissive echo. + const res = NextResponse.json({ ok: true }); + const req = new Request("https://server.example.com/api/v1/models", { + headers: { Origin: "https://evil.example" }, + }); + applyCorsHeaders(res, req, true); + assert.equal(res.headers.get("Access-Control-Allow-Origin"), null); + }); + + it("CLIENT_API: a CORS preflight (OPTIONS) is still allowed through (relaxForTokenAuth)", () => { + // Preflight never carries the auth header; blocking it would break the + // credentialed request that follows, so OPTIONS keeps the permissive echo. + const res = new Response(null, { status: 204 }); + const req = new Request("https://server.example.com/api/v1/models", { + method: "OPTIONS", + headers: { Origin: "http://localhost" }, + }); + applyCorsHeaders(res, req, true); + assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost"); + }); + it("MANAGEMENT: stays fail-closed for arbitrary Origin with no allowlist (relax off)", () => { const res = NextResponse.json({ ok: true }); const req = new Request("https://server.example.com/api/keys", { @@ -213,7 +239,11 @@ describe("cors/origins.applyCorsHeaders", () => { it("CLIENT_API: appends Vary: Accept-Encoding even without an Origin header (#6737)", () => { const res = NextResponse.json({ ok: true }); - const req = new Request("https://server.example.com/api/v1/models"); + // Token-carrying request (post-GHSA-7px7 the permissive echo requires a + // credential); this test's point is the Vary: Accept-Encoding stamp. + const req = new Request("https://server.example.com/api/v1/models", { + headers: { "x-api-key": "omr_test_key" }, + }); applyCorsHeaders(res, req, true); assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*"); assert.match(res.headers.get("Vary") || "", /Accept-Encoding/);