From 6aa78f2c7a962226582f0cac1722e37b330d8d70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:00:43 +0000 Subject: [PATCH] fix(functions-compiler): drop lang_version from Stripe's client user agent stripe-node before its workerd build sends workerd's fixed process.version (v22.19.0) as lang_version in X-Stripe-Client-User-Agent, and Stripe then warns customers about CVE-2026-111497. The shim's fetch wrapper now removes that one field from the header's JSON, before static-egress routing, and leaves the header alone when it does not parse. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DSAVJCkPcs1gz2QDpBQZQZ --- .../functions-compiler/src/static-egress.ts | 47 ++++++++++++- .../test/static-egress.test.ts | 67 +++++++++++++++++++ .../test/stripe-user-agent.e2e.test.ts | 35 ++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 packages/functions-compiler/test/stripe-user-agent.e2e.test.ts diff --git a/packages/functions-compiler/src/static-egress.ts b/packages/functions-compiler/src/static-egress.ts index 8de4b6a24..99093cf2c 100644 --- a/packages/functions-compiler/src/static-egress.ts +++ b/packages/functions-compiler/src/static-egress.ts @@ -130,11 +130,56 @@ function isExcludedHostname( return false; } +// stripe-node before its workerd build reports workerd's fixed process.version +// as lang_version, and Stripe then warns the account about an outdated Node. +const STRIPE_CLIENT_USER_AGENT_HEADER = "X-Stripe-Client-User-Agent"; + +function withoutLangVersion(value: string | null): string | null { + if (value === null) return null; + try { + const parsed: unknown = JSON.parse(value); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + !("lang_version" in parsed) + ) { + return null; + } + delete (parsed as Record).lang_version; + return JSON.stringify(parsed); + } catch { + return null; + } +} + +export function stripStripeLangVersion( + input: RequestInfo | URL, + init?: RequestInit, +): [RequestInfo | URL, RequestInit | undefined] { + if (init?.headers !== undefined) { + const headers = new Headers(init.headers); + const value = withoutLangVersion(headers.get(STRIPE_CLIENT_USER_AGENT_HEADER)); + if (value === null) return [input, init]; + headers.set(STRIPE_CLIENT_USER_AGENT_HEADER, value); + return [input, { ...init, headers }]; + } + if (input instanceof Request) { + const value = withoutLangVersion(input.headers.get(STRIPE_CLIENT_USER_AGENT_HEADER)); + if (value === null) return [input, init]; + const headers = new Headers(input.headers); + headers.set(STRIPE_CLIENT_USER_AGENT_HEADER, value); + return [new Request(input, { headers }), init]; + } + return [input, init]; +} + export function createRequestScopedStaticEgressFetch( originalFetch: typeof fetch, readEnvironment: () => Record, ): typeof fetch { - return (input: RequestInfo | URL, init?: RequestInit) => { + return (rawInput: RequestInfo | URL, rawInit?: RequestInit) => { + const [input, init] = stripStripeLangVersion(rawInput, rawInit); const env = readEnvironment(); const binding = env[STATIC_EGRESS_BINDING_NAME]; const enableSecret = env[STATIC_EGRESS_ENABLED_ENV]; diff --git a/packages/functions-compiler/test/static-egress.test.ts b/packages/functions-compiler/test/static-egress.test.ts index 8f0e5bb06..a9dc61d2c 100644 --- a/packages/functions-compiler/test/static-egress.test.ts +++ b/packages/functions-compiler/test/static-egress.test.ts @@ -284,3 +284,70 @@ describe("createRequestScopedStaticEgressFetch", () => { } }); }); + +describe("stripStripeLangVersion", () => { + const header = "X-Stripe-Client-User-Agent"; + const agent = { bindings_version: "20.4.1", lang: "node", lang_version: "v22.19.0", platform: "linux" }; + const stripped = { bindings_version: "20.4.1", lang: "node", platform: "linux" }; + const noEgress = () => ({}); + + it("drops only lang_version from init headers", async () => { + const ordinaryFetch = vi.fn().mockResolvedValue(new Response()); + const routedFetch = createRequestScopedStaticEgressFetch(ordinaryFetch, noEgress); + + await routedFetch("https://api.stripe.com/v1/customers", { + method: "POST", + headers: { [header]: JSON.stringify(agent), Authorization: "Bearer sk_test" }, + }); + + const [, init] = ordinaryFetch.mock.calls[0]; + const headers = new Headers(init?.headers); + expect(JSON.parse(headers.get(header)!)).toEqual(stripped); + expect(headers.get("Authorization")).toBe("Bearer sk_test"); + expect(init?.method).toBe("POST"); + }); + + it("drops lang_version from a Request's own headers", async () => { + const ordinaryFetch = vi.fn().mockResolvedValue(new Response()); + const routedFetch = createRequestScopedStaticEgressFetch(ordinaryFetch, noEgress); + + await routedFetch( + new Request("https://stripe.internal.example/v1/charges", { + headers: { [header]: JSON.stringify(agent) }, + }), + ); + + const [input] = ordinaryFetch.mock.calls[0]; + expect(JSON.parse((input as Request).headers.get(header)!)).toEqual(stripped); + }); + + it.each(["not json", JSON.stringify(stripped), JSON.stringify(["lang_version"])])( + "passes %s through untouched", + async (value) => { + const ordinaryFetch = vi.fn().mockResolvedValue(new Response()); + const routedFetch = createRequestScopedStaticEgressFetch(ordinaryFetch, noEgress); + const init = { headers: { [header]: value } }; + + await routedFetch("https://api.stripe.com/v1/customers", init); + + expect(ordinaryFetch).toHaveBeenCalledWith("https://api.stripe.com/v1/customers", init); + }, + ); + + it("strips before routing through dedicated egress", async () => { + const ordinaryFetch = vi.fn(); + const bindingFetch = vi.fn().mockResolvedValue(new Response()); + const routedFetch = createRequestScopedStaticEgressFetch(ordinaryFetch, () => ({ + STATIC_EGRESS: { fetch: bindingFetch }, + BASE44_STATIC_EGRESS_ENABLED: "1", + })); + + await routedFetch("https://api.stripe.com/v1/customers", { + headers: { [header]: JSON.stringify(agent) }, + }); + + const [, init] = bindingFetch.mock.calls[0]; + expect(JSON.parse(new Headers(init.headers).get(header)!)).toEqual(stripped); + expect(ordinaryFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/functions-compiler/test/stripe-user-agent.e2e.test.ts b/packages/functions-compiler/test/stripe-user-agent.e2e.test.ts new file mode 100644 index 000000000..edaf6f49a --- /dev/null +++ b/packages/functions-compiler/test/stripe-user-agent.e2e.test.ts @@ -0,0 +1,35 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +import { expect, it } from "vitest"; + +import { bundleOrThrow } from "./helpers"; +import { runInWorkerd } from "./workerd"; + +it("stripe-node's client user agent leaves workerd without lang_version", async () => { + const seen: (string | undefined)[] = []; + const server = http.createServer((req, res) => { + seen.push(req.headers["x-stripe-client-user-agent"] as string | undefined); + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { type: "invalid_request_error", message: "echo" } })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + try { + const m = await bundleOrThrow(` + import Stripe from "npm:stripe@20.4.1"; + Deno.serve(async () => { + const stripe = new Stripe("sk_test_invalid", { host: "127.0.0.1", port: ${port}, protocol: "http", maxNetworkRetries: 0 }); + try { await stripe.customers.list({ limit: 1 }); } catch (e) { return new Response(e.type); } + return new Response("unexpected success"); + }); + `); + const { text } = await runInWorkerd(m); + expect(text).toBe("StripeAuthenticationError"); + const agent = JSON.parse(seen[0]!); + expect(agent).not.toHaveProperty("lang_version"); + expect(agent).toMatchObject({ bindings_version: "20.4.1", lang: "node", platform: "linux" }); + } finally { + server.close(); + } +}, 120_000);