Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion packages/functions-compiler/src/static-egress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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<string, unknown>,
): 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];
Expand Down
67 changes: 67 additions & 0 deletions packages/functions-compiler/test/static-egress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<typeof fetch>();
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();
});
});
35 changes: 35 additions & 0 deletions packages/functions-compiler/test/stripe-user-agent.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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);
Loading