Skip to content

Commit bb34a18

Browse files
avalleteclaude
andauthored
fix(stack): leave CORS on /functions/v1 to the function (CLI-2923) (#7472)
## Problem The stack's HttpProxy answers every preflight with 204 and `Access-Control-Allow-Origin: *`, and adds `*` to function responses that set no Allow-Origin. A function that restricts origins never sees the preflight, and its denied responses become readable from any origin. On the platform, and on self-hosted after supabase/supabase#52709, the function decides. Resolves [CLI-2923](https://linear.app/supabase/issue/CLI-2923/align-experimental-cli-httpproxy-with-self-hosted-gateway-changes). ## Solution 1. `HttpRoute.upstreamCors`: the proxy skips its CORS headers and preflight shortcut on that route. The proxy's own 502 keeps `*`. 2. The `/functions/v1` shared route sets it. 3. `serve.main.ts`: router errors (404, 401, worker errors, 500) send `Access-Control-Allow-Origin: *`, `Allow-Headers` and `Expose-Headers: sb-error-code`, matching supabase/supabase#52710, so browsers can read them on requests that skip the preflight. A preflight to a missing or crashing function still fails, since it is not a 2xx. Functions called from a browser must now handle CORS themselves, as on the platform. The legacy Kong path is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 47a0f63 commit bb34a18

7 files changed

Lines changed: 105 additions & 10 deletions

File tree

‎packages/stack/src/HttpProxy.integration.test.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { WebSocket, WebSocketServer } from "ws";
99
import { captureLogs } from "../tests/logs.ts";
1010
import { ProxyError } from "./Proxy.ts";
1111
import { makeHttpProxy, type HttpAccess, type HttpRoute } from "./HttpProxy.ts";
12+
import { sharedRoutes } from "./host/Endpoints.ts";
1213

1314
const listen = (server: Server | NetServer, options?: { readonly beforeClose?: () => void }) =>
1415
Effect.acquireRelease(
@@ -746,6 +747,54 @@ it.live("adds a route's default headers only when the client sent none", () =>
746747
).pipe(Effect.provide(NodeServices.layer)),
747748
);
748749

750+
it.live(
751+
"leaves CORS on the functions route to the function while other routes keep the gateway's",
752+
() =>
753+
Effect.scoped(
754+
Effect.gen(function* () {
755+
const backend = createServer((incoming, response) => {
756+
if (incoming.headers.origin === "https://app.example.com")
757+
response.setHeader("access-control-allow-origin", "https://app.example.com");
758+
response.statusCode = incoming.method === "OPTIONS" ? 403 : 200;
759+
response.end(incoming.method);
760+
});
761+
const backendAddress = yield* listen(backend);
762+
const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 });
763+
const keys = {
764+
publishableKey: "sb_publishable_example",
765+
secretKey: "sb_secret_example",
766+
anonKey: "anon.jwt.value",
767+
serviceRoleKey: "service.role.jwt",
768+
};
769+
yield* proxy.setRoutes([
770+
...(
771+
sharedRoutes({ service: "functions" }, "http", keys, () => Effect.die("unused")) ?? []
772+
).map((route) => ({ ...route, id: "functions", target: Effect.succeed(backendAddress) })),
773+
{ id: "rest", prefix: "/rest/v1", target: Effect.succeed(backendAddress) },
774+
]);
775+
const client = yield* HttpClient.HttpClient;
776+
const send = (method: "OPTIONS" | "POST", path: string, origin: string) =>
777+
client.execute(
778+
HttpClientRequest.make(method)(`http://127.0.0.1:${proxy.port}${path}`).pipe(
779+
HttpClientRequest.setHeader("origin", origin),
780+
),
781+
);
782+
783+
const preflight = yield* send("OPTIONS", "/functions/v1/fn", "https://other.example.com");
784+
expect(preflight.status).toBe(403);
785+
expect(preflight.headers["access-control-allow-origin"]).toBeUndefined();
786+
const denied = yield* send("POST", "/functions/v1/fn", "https://other.example.com");
787+
expect(yield* denied.text).toBe("POST");
788+
expect(denied.headers["access-control-allow-origin"]).toBeUndefined();
789+
const allowed = yield* send("POST", "/functions/v1/fn", "https://app.example.com");
790+
expect(allowed.headers["access-control-allow-origin"]).toBe("https://app.example.com");
791+
const rest = yield* send("OPTIONS", "/rest/v1/todos", "https://other.example.com");
792+
expect(rest.status).toBe(204);
793+
expect(rest.headers["access-control-allow-origin"]).toBe("*");
794+
}),
795+
).pipe(Effect.provide(Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer))),
796+
);
797+
749798
it.live("returns a gateway error naming the route and cause when a target cannot wake", () => {
750799
const logs: Array<string> = [];
751800
return Effect.scoped(
@@ -757,15 +806,25 @@ it.live("returns a gateway error naming the route and cause when a target cannot
757806
prefix: "/",
758807
target: Effect.fail(new ProxyError({ message: "readiness failed" })),
759808
},
809+
{
810+
id: "functions",
811+
prefix: "/functions/v1",
812+
upstreamCors: true,
813+
target: Effect.fail(new ProxyError({ message: "readiness failed" })),
814+
},
760815
]);
761816
const client = yield* HttpClient.HttpClient;
762817
const response = yield* client.get(`http://127.0.0.1:${proxy.port}/`);
763818
expect(response.status).toBe(502);
764819
expect(response.headers["access-control-allow-origin"]).toBe("*");
765820
expect(yield* response.text).toBe("Bad Gateway");
766-
expect(logs).toHaveLength(1);
821+
const functions = yield* client.get(`http://127.0.0.1:${proxy.port}/functions/v1/fn`);
822+
expect(functions.status).toBe(502);
823+
expect(functions.headers["access-control-allow-origin"]).toBe("*");
824+
expect(logs).toHaveLength(2);
767825
expect(logs[0]).toContain("Route rest request failed");
768826
expect(logs[0]).toContain("readiness failed");
827+
expect(logs[1]).toContain("Route functions request failed");
769828
}),
770829
).pipe(
771830
Effect.provide(

‎packages/stack/src/HttpProxy.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface HttpRoute {
3737
readonly keyRewrite?: HttpRouteKeyRewrite;
3838
/** Sends each write over a fresh upstream connection; reads still share the pool and its retry. */
3939
readonly freshWrites?: boolean;
40+
/** Leaves CORS, preflights included, to the upstream, like Edge Functions on the platform. */
41+
readonly upstreamCors?: boolean;
4042
}
4143

4244
/** Configures Supabase API-key rewriting for one HTTP route. */
@@ -447,7 +449,7 @@ const forward = Effect.fn("HttpProxy.forward")(function* (
447449
sent.bytes += chunk.length;
448450
});
449451
response.once("finish", onFinish);
450-
setCors(response, request);
452+
if (route.upstreamCors !== true) setCors(response, request);
451453
response.statusCode = value.statusCode ?? 502;
452454
for (const [name, header] of Object.entries(value.headers)) {
453455
if (header !== undefined && !hopByHop.has(name.toLowerCase()))
@@ -676,8 +678,9 @@ export const makeHttpProxy = (options: {
676678
const route = yield* Ref.get(routes).pipe(
677679
Effect.map((current) => routeFor(request.url ?? "/", current)),
678680
);
679-
setCors(response, request);
680-
if (request.method === "OPTIONS") respond(response, sent, 204);
681+
if (route?.upstreamCors !== true) setCors(response, request);
682+
if (request.method === "OPTIONS" && route?.upstreamCors !== true)
683+
respond(response, sent, 204);
681684
else if (route === undefined) respond(response, sent, 404, "Not Found");
682685
else {
683686
yield* proxyRequest(request, response, route, agent, sent).pipe(

‎packages/stack/src/Network.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ export type NetworkRuntime = "native" | "docker" | "podman";
1111
/* `target` serves a route from another backend than its endpoint's, like PostgREST's admin port. */
1212
type RouteContribution = Pick<
1313
HttpRoute,
14-
"prefix" | "upstreamPrefix" | "upstreamHost" | "addHeaders" | "forwardedPrefix" | "keyRewrite"
14+
| "prefix"
15+
| "upstreamPrefix"
16+
| "upstreamHost"
17+
| "addHeaders"
18+
| "forwardedPrefix"
19+
| "keyRewrite"
20+
| "upstreamCors"
1521
> &
1622
Partial<Pick<HttpRoute, "target">>;
1723

‎packages/stack/src/functions/generated/serve-main-bundle.ts‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/stack/src/functions/serve-main-bundler.integration.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { defaultFunctionsBootstrap } from "./generated/serve-main-bundle.ts";
88
type ServeOptions = {
99
readonly handler: (request: Request) => Promise<Response>;
1010
readonly onListen: () => void;
11+
readonly onError: (error: unknown) => Response;
1112
};
1213

1314
class MissingFixture extends Data.TaggedError("MissingFixture") {}
@@ -263,6 +264,8 @@ describe("stack-owned functions bootstrap", () => {
263264
);
264265
const invalidSignature = yield* invoke(wrongToken);
265266
expect(invalidSignature.status).toBe(401);
267+
expect(invalidSignature.headers.get("access-control-allow-origin")).toBe("*");
268+
expect(invalidSignature.headers.get("access-control-expose-headers")).toBe("sb-error-code");
266269
expect(yield* Effect.tryPromise(() => invalidSignature.json())).toMatchObject({
267270
code: "UNAUTHORIZED_JWT",
268271
});
@@ -356,6 +359,10 @@ describe("stack-owned functions bootstrap", () => {
356359
registered.handler(new Request("http://127.0.0.1/missing")),
357360
);
358361
expect(missing.status).toBe(404);
362+
expect(missing.headers.get("access-control-allow-origin")).toBe("*");
363+
const crashed = registered.onError(new Error("boom"));
364+
expect(crashed.status).toBe(500);
365+
expect(crashed.headers.get("access-control-allow-origin")).toBe("*");
359366
expect(yield* Effect.tryPromise(() => missing.text())).toBe("Function not found");
360367
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
361368
);
@@ -519,6 +526,7 @@ describe("stack-owned functions bootstrap", () => {
519526
handler(new Request("http://127.0.0.1/hello")),
520527
);
521528
expect(response.status).toBe(500);
529+
expect(response.headers.get("access-control-allow-origin")).toBe("*");
522530
expect(yield* Effect.tryPromise(() => response.json())).toMatchObject({
523531
code: "WORKER_ERROR",
524532
});

‎packages/stack/src/functions/serve.main.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,17 @@ const getResponse = (
208208
return new Response(body, { status, headers });
209209
};
210210

211+
/** Lets browsers read router errors on requests that skip the preflight; preflights still fail. */
212+
const ERROR_CORS_HEADERS = {
213+
"Access-Control-Allow-Origin": "*",
214+
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey",
215+
"Access-Control-Expose-Headers": "sb-error-code",
216+
};
217+
211218
const getAuthErrorResponse = ({ code, message = "Invalid JWT" }: AuthFailure) =>
212219
getResponse({ code, message, msg: message }, STATUS_CODE.Unauthorized, {
220+
...ERROR_CORS_HEADERS,
213221
"sb-error-code": code,
214-
"Access-Control-Expose-Headers": "sb-error-code",
215222
});
216223

217224
const getWorkerErrorResponse = (error: unknown) => {
@@ -223,6 +230,7 @@ const getWorkerErrorResponse = (error: unknown) => {
223230
message: SB_SPECIFIC_ERROR_REASON[sbCode],
224231
},
225232
sbCode,
233+
ERROR_CORS_HEADERS,
226234
);
227235
}
228236
}
@@ -232,6 +240,7 @@ const getWorkerErrorResponse = (error: unknown) => {
232240
message: "Request failed due to an internal server error",
233241
},
234242
STATUS_CODE.InternalServerError,
243+
ERROR_CORS_HEADERS,
235244
);
236245
};
237246

@@ -423,9 +432,11 @@ Deno.serve({
423432
if (pathname === "/_internal/metric")
424433
return Response.json(yield* foreign(() => EdgeRuntime.getRuntimeMetrics()));
425434
const functionName = pathname.split("/")[1];
426-
if (!functionName) return getResponse("Function not found", STATUS_CODE.NotFound);
435+
if (!functionName)
436+
return getResponse("Function not found", STATUS_CODE.NotFound, ERROR_CORS_HEADERS);
427437
const config = yield* functionConfig(functionName);
428-
if (!config) return getResponse("Function not found", STATUS_CODE.NotFound);
438+
if (!config)
439+
return getResponse("Function not found", STATUS_CODE.NotFound, ERROR_CORS_HEADERS);
429440
yield* warnPlainDenoConfig(functionName, config);
430441
if (request.method !== "OPTIONS" && config.verifyJWT) {
431442
const token = getAuthToken(request);
@@ -521,5 +532,6 @@ Deno.serve({
521532
message: "Request failed due to an internal server error",
522533
},
523534
500,
535+
ERROR_CORS_HEADERS,
524536
),
525537
});

‎packages/stack/src/host/Endpoints.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,14 @@ export const sharedRoutes = (
119119
{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } },
120120
];
121121
case "functions":
122-
return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "sb-api-key", keys } }];
122+
return [
123+
{
124+
prefix: route,
125+
upstreamPrefix: "/",
126+
keyRewrite: { policy: "sb-api-key", keys },
127+
upstreamCors: true,
128+
},
129+
];
123130
default:
124131
return [{ prefix: route, upstreamPrefix: "/" }];
125132
}

0 commit comments

Comments
 (0)