From 390bcb59a61c24c56d74ff03d3b1544ff78847f7 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Mon, 6 Jul 2026 11:41:31 +0200 Subject: [PATCH] fix(stack): echo requested headers on CORS preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy short-circuits every OPTIONS with 204 and a static allow-headers list, so the backing services' own CORS handling is never consulted — and the static list predates what browser clients send today (supabase-js 2.x adds x-supabase-api-version, PostgREST reads Prefer and the profile headers), so credentialless browser calls through the proxy failed CORS preflight (net::ERR_FAILED, observed at a real web e2e). This is the same skew class auth fixed for itself in supabase/auth#1612 after supabase/auth#1589 — a fix this proxy's short-circuit masks. Preflights now echo the requested headers, which is exactly what the classic CLI's proxy does: its kong.yml ships the bare cors plugin, and Kong's handler reflects Access-Control-Request-Headers when no header list is configured. Echo over a wildcard because `*` is literal (not a wildcard) for credentialed requests. The echo is preflight-only; the static list stays for preflights naming no headers and for non-preflight responses. Regression tests cover the echo and its preflight-only boundary. Co-Authored-By: Claude Fable 5 --- packages/stack/src/ApiProxy.ts | 21 ++++++++++++++++-- packages/stack/src/ApiProxy.unit.test.ts | 27 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts index 12f258235e..78dda3ec3d 100644 --- a/packages/stack/src/ApiProxy.ts +++ b/packages/stack/src/ApiProxy.ts @@ -98,9 +98,23 @@ const CORS_HEADERS: ReadonlyArray = [ function addCorsHeaders( response: HttpServerResponse.HttpServerResponse, + requestedHeaders?: string, ): HttpServerResponse.HttpServerResponse { + // On a preflight, echo the requested headers: browser clients evolve + // faster than any static list (supabase-js added x-supabase-api-version; + // PostgREST reads Prefer and the profile headers), and echoing is what + // Kong's cors plugin — the classic CLI's proxy — does when no header + // list is configured. The static list stays as the fallback for + // preflights that name no headers and for non-preflight responses. return CORS_HEADERS.reduce( - (res, [name, value]) => HttpServerResponse.setHeader(res, name, value), + (res, [name, value]) => + HttpServerResponse.setHeader( + res, + name, + name === "access-control-allow-headers" && requestedHeaders !== undefined + ? requestedHeaders + : value, + ), response, ); } @@ -370,7 +384,10 @@ export class ApiProxy extends Context.Service< const req = yield* HttpServerRequest.HttpServerRequest; if (req.method === "OPTIONS") { - return addCorsHeaders(HttpServerResponse.empty({ status: 204 })); + return addCorsHeaders( + HttpServerResponse.empty({ status: 204 }), + req.headers["access-control-request-headers"], + ); } const response = yield* httpEffect; diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts index d2b3a00bbc..6a3b3f18f4 100644 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ b/packages/stack/src/ApiProxy.unit.test.ts @@ -205,6 +205,33 @@ describe("ApiProxy", () => { expect(res.headers.get("access-control-allow-origin")).toBe("*"); }); + test("preflight echoes the requested headers", async () => { + // Browser clients evolve faster than any static allow list + // (supabase-js's x-supabase-api-version, PostgREST's Prefer); the + // preflight reflects what was asked for — the same default Kong's cors + // plugin gives the classic CLI's proxy. + const res = await fetch(`${proxyUrl}/rest/v1/users`, { + method: "OPTIONS", + headers: { + "access-control-request-headers": "authorization,prefer,x-supabase-api-version", + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-headers")).toBe( + "authorization,prefer,x-supabase-api-version", + ); + }); + + test("the echo is preflight-only: non-OPTIONS requests keep the static list", async () => { + const res = await fetch(`${proxyUrl}/health`, { + headers: { + "access-control-request-headers": "x-arbitrary-header", + }, + }); + expect(res.headers.get("access-control-allow-headers")).toContain("apikey"); + expect(res.headers.get("access-control-allow-headers")).not.toContain("x-arbitrary-header"); + }); + // --------------------------------------------------------------------------- // Auth transformation — publishableKey → anonJwt // ---------------------------------------------------------------------------