diff --git a/packages/paykit/src/webhook/__tests__/webhook.api.test.ts b/packages/paykit/src/webhook/__tests__/webhook.api.test.ts new file mode 100644 index 00000000..29e9c0c2 --- /dev/null +++ b/packages/paykit/src/webhook/__tests__/webhook.api.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { shouldAllowUnsignedPayload } from "../webhook.api"; + +// shouldAllowUnsignedPayload gates whether the public webhook endpoint skips +// Stripe signature verification entirely. It must only ever be true because +// an operator explicitly opted in, never because of the server's ambient +// NODE_ENV - see the comment on the function for why. +describe("shouldAllowUnsignedPayload", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PAYKIT_ALLOW_UNSIGNED_PAYLOADS; + delete process.env.PAYKIT_ALLOW_STALE_SIGNATURES; + delete process.env.NODE_ENV; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("requires the cloud-replay header even with an explicit opt-in", () => { + process.env.PAYKIT_ALLOW_UNSIGNED_PAYLOADS = "1"; + expect(shouldAllowUnsignedPayload(new Headers())).toBe(false); + }); + + it("does not allow unsigned payloads from NODE_ENV=development alone", () => { + process.env.NODE_ENV = "development"; + const headers = new Headers({ "x-paykit-cloud-replay": "1" }); + expect(shouldAllowUnsignedPayload(headers)).toBe(false); + }); + + it("does not allow unsigned payloads from NODE_ENV=test alone", () => { + process.env.NODE_ENV = "test"; + const headers = new Headers({ "x-paykit-cloud-replay": "1" }); + expect(shouldAllowUnsignedPayload(headers)).toBe(false); + }); + + it("allows unsigned payloads with the documented opt-in flag", () => { + process.env.PAYKIT_ALLOW_UNSIGNED_PAYLOADS = "1"; + const headers = new Headers({ "x-paykit-cloud-replay": "1" }); + expect(shouldAllowUnsignedPayload(headers)).toBe(true); + }); + + it("allows unsigned payloads with the legacy alias flag", () => { + process.env.PAYKIT_ALLOW_STALE_SIGNATURES = "1"; + const headers = new Headers({ "x-paykit-cloud-replay": "1" }); + expect(shouldAllowUnsignedPayload(headers)).toBe(true); + }); +}); diff --git a/packages/paykit/src/webhook/webhook.api.ts b/packages/paykit/src/webhook/webhook.api.ts index 16490805..bed56c67 100644 --- a/packages/paykit/src/webhook/webhook.api.ts +++ b/packages/paykit/src/webhook/webhook.api.ts @@ -9,7 +9,18 @@ function headersToRecord(headers: Headers): Record { return result; } -function shouldAllowUnsignedPayload(headers: Headers): boolean { +/** + * Whether an incoming request may skip Stripe signature verification. Only the + * explicit opt-in env vars gate this: NODE_ENV isn't a reliable signal for + * "this deployment is safe to leave unauthenticated". Self-hosted containers + * and staging environments routinely run without NODE_ENV=production, or with + * it set to "development"/"test", while still processing real webhooks - and + * this endpoint has no other authentication, so treating NODE_ENV as consent + * turned the `x-paykit-cloud-replay` header into an unauthenticated way to + * post forged, fully-trusted billing events (subscriptions, invoices, + * payments) on any such deployment. + */ +export function shouldAllowUnsignedPayload(headers: Headers): boolean { if (headers.get("x-paykit-cloud-replay") !== "1") { return false; } @@ -17,9 +28,7 @@ function shouldAllowUnsignedPayload(headers: Headers): boolean { return ( process.env.PAYKIT_ALLOW_UNSIGNED_PAYLOADS === "1" || // Legacy alias kept for local replay compatibility; remove in a future major. - process.env.PAYKIT_ALLOW_STALE_SIGNATURES === "1" || - process.env.NODE_ENV === "development" || - process.env.NODE_ENV === "test" + process.env.PAYKIT_ALLOW_STALE_SIGNATURES === "1" ); }