mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user