Skip to content
Open
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
50 changes: 50 additions & 0 deletions packages/paykit/src/webhook/__tests__/webhook.api.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 13 additions & 4 deletions packages/paykit/src/webhook/webhook.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,26 @@ function headersToRecord(headers: Headers): Record<string, string> {
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;
}

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"
);
}

Expand Down