From b2bfdea2f27dd3ad49faa6c05507464c5abb45b3 Mon Sep 17 00:00:00 2001 From: Aroh Maurya Date: Wed, 30 Sep 2026 07:26:34 +0530 Subject: [PATCH] Don't let NODE_ENV disable webhook signature verification shouldAllowUnsignedPayload skipped Stripe signature checking on the public webhook endpoint whenever NODE_ENV was "development" or "test", as long as a request carried x-paykit-cloud-replay: 1. That header has no secret behind it, so on any deployment where NODE_ENV isn't exactly "production" (self-hosted containers that never set it, staging environments, anything misconfigured), anyone could POST a forged Stripe event body with that one header and have it processed as fully trusted: fake subscriptions, invoices, payments. Drop the NODE_ENV checks and keep only the explicit opt-in env vars that already exist for this (PAYKIT_ALLOW_UNSIGNED_PAYLOADS and its legacy alias), so the replay feature still works for anyone who turns it on deliberately. Added a unit test for shouldAllowUnsignedPayload covering the header requirement, the removed NODE_ENV paths, and both opt-in flags. --- .../src/webhook/__tests__/webhook.api.test.ts | 50 +++++++++++++++++++ packages/paykit/src/webhook/webhook.api.ts | 17 +++++-- 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 packages/paykit/src/webhook/__tests__/webhook.api.test.ts 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" ); }