fix(security): require a credential before echoing the CORS Origin on /v1

The /v1* surface echoes an arbitrary Origin so token-authenticated browser /
Electron clients can read the response (#5242) — safe only because
Authorization / x-api-key are never auto-attached. On a keyless install /v1 is
served anonymously, so a credential-less cross-origin page was echoed its own
Origin and could drive the gateway (GHSA-7px7). The echo now requires the request
to actually carry a credential (Authorization / x-api-key / x-goog-api-key or the
auth_token cookie), or be a CORS preflight; truly anonymous cross-origin requests
no longer get it. #5242 token and dashboard-session clients are unaffected.

Reported by @Upshivam786 via GHSA-7px7-29v2-m97p.
This commit is contained in:
Xiangzhe
2026-08-21 14:36:10 -03:00
parent ad15811e1a
commit f1b4a3d93e
2 changed files with 64 additions and 6 deletions

View File

@@ -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) {

View File

@@ -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/);