Skip to content

Don't let NODE_ENV disable webhook signature verification - #221

Open
aroh3006 wants to merge 1 commit into
getpaykit:mainfrom
aroh3006:fix/webhook-node-env-signature-bypass
Open

aroh3006 wants to merge 1 commit into
getpaykit:mainfrom
aroh3006:fix/webhook-node-env-signature-bypass

Conversation

@aroh3006

@aroh3006 aroh3006 commented Sep 30, 2026 •

Copy link
Copy Markdown

security.api_token wasn't the issue here, this is in the webhook route: shouldAllowUnsignedPayload (packages/paykit/src/webhook/webhook.api.ts) skips Stripe signature verification on POST /webhook whenever a request carries x-paykit-cloud-replay: 1 and NODE_ENV is "development" or "test".

The header has no secret behind it, it's just a magic string. So on any deployment where NODE_ENV isn't exactly "production" (a self-hosted container that never sets it, a staging environment, anything misconfigured), an external request can send that one header along with an arbitrary JSON body and have it parsed by parseUnsignedStripeEvent and processed as a fully trusted Stripe event, no signature, no webhookSecret involved at all. That flows straight into handleWebhook/processWebhookEvent and applies subscription/invoice/payment/payment-method actions to the database. Their own stripe-provider.test.ts already demonstrates allowUnsignedPayload: true accepts an arbitrary event with zero verification, which is the intended behavior for that flag; the gap is what's allowed to set it to true.

Two explicit opt-in env vars already exist for the legitimate "cloud replay" use case (PAYKIT_ALLOW_UNSIGNED_PAYLOADS, and its legacy alias PAYKIT_ALLOW_STALE_SIGNATURES). This PR removes the two NODE_ENV checks and keeps only those, so the replay feature still works for anyone who turns it on deliberately, but an ambient NODE_ENV value nobody thought of as a security setting can no longer disable webhook auth.

Added webhook.api.test.ts covering the header requirement, that NODE_ENV=development and NODE_ENV=test alone no longer allow it, and that both opt-in flags still do. Confirmed those two cases fail against the current code and pass after the fix.

Ran test:unit: 148 passed, 1 pre-existing unrelated failure (a Windows shell-quoting difference in cli/listen.test.ts, present on main before this change too, nothing to do with this). Also ran oxlint --deny-warnings and oxfmt --check on the changed files (clean) and tsc --build for the paykit package (clean).


Summary by cubic

Closes a webhook authentication bypass where a request carrying an x-paykit-cloud-replay: 1 header could skip Stripe signature verification on POST /webhook whenever NODE_ENV was "development" or "test". The header has no secret, so forged billing events (subscriptions, invoices, payments) could be processed as fully trusted on any deployment where NODE_ENV wasn't exactly "production". Unsigned payloads now require an explicit opt-in via PAYKIT_ALLOW_UNSIGNED_PAYLOADS (or legacy alias PAYKIT_ALLOW_STALE_SIGNATURES), and the NODE_ENV checks are removed.

Behavior

  • Deployments relying on ambient NODE_ENV values to allow unsigned replay must now set one of the two opt-in flags.
  • Adds tests covering the header requirement, the removed NODE_ENV paths (which fail against the old code), and both opt-in flags.

Written for commit b2bfdea. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Security
    • Unsigned webhook payloads are no longer accepted solely because the app is running in development or test mode. Acceptance now requires the cloud-replay header and an explicit opt-in setting, including the supported legacy setting.

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.
@vercel

vercel Bot commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
paykit Skipped Skipped Sep 30, 2026 1:57am UTC

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: ceaa010b-86f7-44cf-8fc9-ec66ca76011e

📥 Commits

Reviewing files that changed from the base of the PR and between c7572ff and b2bfdea.

📒 Files selected for processing (2)
  • packages/paykit/src/webhook/__tests__/webhook.api.test.ts
  • packages/paykit/src/webhook/webhook.api.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Unsigned webhook payloads now require the cloud-replay header and an explicit opt-in environment variable. Development and test modes no longer grant access by themselves. Tests cover these conditions.

Changes

Unsigned payload handling

Layer / File(s) Summary
Explicit unsigned-payload gate
packages/paykit/src/webhook/webhook.api.ts, packages/paykit/src/webhook/__tests__/webhook.api.test.ts
The check retains the cloud-replay header and explicit opt-in requirements, and removes development and test mode as alternatives. Tests cover rejection and acceptance cases and restore environment variables after each test.

Priority: ⬆️ High

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to b2bfd

Unsigned webhooks now require explicit opt-in rather than development or test mode alone. No actionable merge-blocking risk remains in the supplied change context; merge after normal checks pass.

Security Architecture Review

Security architecture risk: ⚪ Minimal · up to b2bfd

The change tightens webhook authentication without expanding authority or changing event-processing behavior. Unsigned replay remains an existing, explicit deployment opt-in rather than being enabled automatically by development or test mode.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Webhook admission protects a path to persistent business-state mutations. On an explicitly opted-in deployment, caller-supplied replay payloads can still reach provider normalization and supported webhook actions. The reviewed evidence does not establish the complete tenant or asset scope of those actions.

Security Findings and Attack Paths

  • inferred — The change removes the inspected attack path in which an external caller supplied the replay marker and relied on development or test mode to bypass Stripe authentication. Remaining explicit unsigned replay exposure is unchanged and is not an introduced PR concern.

Trust Boundaries and Controls

  • observed — When unsigned admission is false, the inspected Stripe path requires a signature and configured webhook secret before event construction. Authentication therefore precedes event claiming and business mutations; removing NODE_ENV authorization does not create an alternative route around this control.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing NODE_ENV from disabling webhook signature verification.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks the replay sign,
Then looks for opt-in, clear and fine.
No mode alone can open the gate,
Tests confirm each required state.
The burrow rests; the rules align.

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

This branch was previously deployed

1 inactive deployment
Preview — b2bfdea2 Deployed Sep 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant