From 267190a71d971af4491aac6f0b5637737e8ff8d9 Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 2 Oct 2026 21:20:39 +0000 Subject: [PATCH 1/3] Reject blank webhook secrets Committed-By-Agent: goose Orbit-Session-Id: f03ac338-1738-453e-b3e0-2f4caa11df0d --- src/StripeEventNotificationHandler.ts | 2 +- src/Webhooks.ts | 4 +- test/StripeEventNotificationHandler.spec.ts | 57 ++++++++++++++++++--- test/Webhook.spec.ts | 55 ++++++++++++++++---- 4 files changed, 99 insertions(+), 19 deletions(-) diff --git a/src/StripeEventNotificationHandler.ts b/src/StripeEventNotificationHandler.ts index 5294a3d1d6..626cc78c49 100644 --- a/src/StripeEventNotificationHandler.ts +++ b/src/StripeEventNotificationHandler.ts @@ -376,7 +376,7 @@ export class StripeEventNotificationHandler extends BaseEventNotificationHandler fallbackCallback: FallbackCallback ) { super(client, fallbackCallback); - if (!webhookSecret) { + if (!webhookSecret || /^[ \t\r\n\f\v]*$/.test(webhookSecret)) { throw new Error('webhookSecret must be a non-empty string'); } } diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 316e1ad991..9c5b077ab3 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -258,7 +258,7 @@ export function createWebhooks( encodedHeader, this.EXPECTED_SCHEME ); - if (!secret) { + if (!secret || /^[ \t\r\n\f\v]*$/.test(secret)) { throw new StripeSignatureVerificationError(header, payload, { message: 'No webhook secret value was provided. It should start with `whsec_`', @@ -304,7 +304,7 @@ export function createWebhooks( encodedHeader, this.EXPECTED_SCHEME ); - if (!secret) { + if (!secret || /^[ \t\r\n\f\v]*$/.test(secret)) { throw new StripeSignatureVerificationError(header, payload, { message: 'No webhook secret value was provided. It should start with `whsec_`', diff --git a/test/StripeEventNotificationHandler.spec.ts b/test/StripeEventNotificationHandler.spec.ts index f9b2f8901f..69cbd4113f 100644 --- a/test/StripeEventNotificationHandler.spec.ts +++ b/test/StripeEventNotificationHandler.spec.ts @@ -709,6 +709,57 @@ describe('StripeEventNotificationHandler', () => { expect(errorThrown).to.be.true; }); + + it('should reject blank webhook secrets', () => { + const blankSecrets = [ + '', + ' ', + '\t', + '\r', + '\n', + '\f', + '\v', + ' \t\r\n\f\v', + ]; + + for (const secret of blankSecrets) { + expect(() => { + stripe.notificationHandler(secret, async () => {}); + }).to.throw('webhookSecret must be a non-empty string'); + } + }); + + it('should preserve surrounding whitespace in the webhook secret', async () => { + const secret = ` \t${DUMMY_WEBHOOK_SECRET}\r\n`; + let fallbackCalled = false; + const handler = stripe.notificationHandler(secret, async () => { + fallbackCalled = true; + }); + const header = stripe.webhooks.generateTestHeaderString({ + payload: v1BillingMeterPayload, + secret, + }); + + await handler.handle(v1BillingMeterPayload, header); + + expect(fallbackCalled).to.be.true; + }); + + it('should accept a non-breaking-space-only webhook secret', async () => { + const secret = '\u00a0'; + let fallbackCalled = false; + const handler = stripe.notificationHandler(secret, async () => { + fallbackCalled = true; + }); + const header = stripe.webhooks.generateTestHeaderString({ + payload: v1BillingMeterPayload, + secret, + }); + + await handler.handle(v1BillingMeterPayload, header); + + expect(fallbackCalled).to.be.true; + }); }); describe('registeredEventTypes', () => { @@ -927,12 +978,6 @@ describe('StripeEventNotificationHandlerWithoutVerification', () => { expect(typeof handler.handle).to.equal('function'); }); - it('should throw when constructing the original handler with an empty webhookSecret', () => { - expect(() => { - stripe.notificationHandler('', async () => {}); - }).to.throw(/webhookSecret must be a non-empty string/); - }); - describe('preHandle', () => { it('should run the registered handler when no preHandle hook is registered', async () => { let callbackCalled = false; diff --git a/test/Webhook.spec.ts b/test/Webhook.spec.ts index ca3f1d7e0f..42fc8f876a 100644 --- a/test/Webhook.spec.ts +++ b/test/Webhook.spec.ts @@ -485,25 +485,60 @@ function createWebhooksTestSuite(stripe) { ); }); - it('should raise a SignatureVerificationError when the signing secret is empty or nullish', async () => { + it('should raise a SignatureVerificationError when the signing secret is blank or nullish', async () => { const header = stripe.webhooks.generateTestHeaderString({ payload: EVENT_PAYLOAD_STRING, secret: SECRET, }); const expectedMessage = /No webhook secret value was provided\. It should start with `whsec_`/; + const blankSecrets = [ + '', + ' ', + '\t', + '\r', + '\n', + '\f', + '\v', + ' \t\r\n\f\v', + null, + undefined, + ]; + + await Promise.all( + blankSecrets.map((secret) => + expect( + verifyHeaderFn(EVENT_PAYLOAD_STRING, header, secret) + ).to.be.rejectedWith( + StripeSignatureVerificationError, + expectedMessage + ) + ) + ); + }); - await expect( - verifyHeaderFn(EVENT_PAYLOAD_STRING, header, '') - ).to.be.rejectedWith(StripeSignatureVerificationError, expectedMessage); + it('should preserve surrounding whitespace in the signing secret', async () => { + const secret = ` \t${SECRET}\r\n`; + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret, + }); - await expect( - verifyHeaderFn(EVENT_PAYLOAD_STRING, header, null) - ).to.be.rejectedWith(StripeSignatureVerificationError, expectedMessage); + expect( + await verifyHeaderFn(EVENT_PAYLOAD_STRING, header, secret, 0) + ).to.equal(true); + }); - await expect( - verifyHeaderFn(EVENT_PAYLOAD_STRING, header, undefined) - ).to.be.rejectedWith(StripeSignatureVerificationError, expectedMessage); + it('should accept a non-breaking-space-only signing secret', async () => { + const secret = '\u00a0'; + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret, + }); + + expect( + await verifyHeaderFn(EVENT_PAYLOAD_STRING, header, secret, 0) + ).to.equal(true); }); describe('custom CryptoProvider', () => { From b13abbb2ac74b3e525c82eb6eeeff7bf7d4ac996 Mon Sep 17 00:00:00 2001 From: Zachary Chua Date: Fri, 2 Oct 2026 16:46:27 -0700 Subject: [PATCH 2/3] Add blank webhook secret changefile Committed-By-Agent: codex Co-authored-by: codex --- ...-10-02_zacchua_reject-blank-webhook-secrets.change.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md diff --git a/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md b/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md new file mode 100644 index 0000000000..81a571b07d --- /dev/null +++ b/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md @@ -0,0 +1,9 @@ +--- +title: Reject ASCII-whitespace-only webhook secrets +pr_url: https://github.com/stripe/stripe-node/pull/2885 +semver_level: patch +jira_tickets_closed: +- RUN_DEVSDK-3378 +--- + +- Reject webhook signing secrets made entirely of ASCII whitespace instead of using them as HMAC keys. From 5ab511c3f08f584075429e103ed7fb65d4710b3f Mon Sep 17 00:00:00 2001 From: Zachary Chua Date: Mon, 19 Oct 2026 12:33:25 -0700 Subject: [PATCH 3/3] Centralize webhook secret validation Committed-By-Agent: codex Co-authored-by: codex --- src/StripeEventNotificationHandler.ts | 3 ++- src/Webhooks.ts | 10 +++++++--- src/utils.ts | 4 ++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/StripeEventNotificationHandler.ts b/src/StripeEventNotificationHandler.ts index a64fedeff8..02d97afbe1 100644 --- a/src/StripeEventNotificationHandler.ts +++ b/src/StripeEventNotificationHandler.ts @@ -1,6 +1,7 @@ import {Stripe} from './stripe.core.js'; import * as Events from './resources/V2/Core/Events.js'; import {WebhookHeader, WebhookPayload} from './Webhooks.js'; +import {isBlankWebhookSecret} from './utils.js'; export interface UnhandledNotificationDetails { isKnownEventType: boolean; @@ -380,7 +381,7 @@ export class StripeEventNotificationHandler extends BaseEventNotificationHandler fallbackCallback: FallbackCallback ) { super(client, fallbackCallback); - if (!webhookSecret || /^[ \t\r\n\f\v]*$/.test(webhookSecret)) { + if (isBlankWebhookSecret(webhookSecret)) { throw new Error('webhookSecret must be a non-empty string'); } } diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 9c5b077ab3..01a91ad9e9 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -5,7 +5,11 @@ import { } from './crypto/CryptoProvider.js'; import {PlatformFunctions} from './platform/PlatformFunctions.js'; import {Event} from './resources/Events.js'; -import {maybeExtractFromCloudProviderEnvelope, parsePayload} from './utils.js'; +import { + isBlankWebhookSecret, + maybeExtractFromCloudProviderEnvelope, + parsePayload, +} from './utils.js'; /** * Value of the `stripe-signature` header from Stripe. @@ -258,7 +262,7 @@ export function createWebhooks( encodedHeader, this.EXPECTED_SCHEME ); - if (!secret || /^[ \t\r\n\f\v]*$/.test(secret)) { + if (isBlankWebhookSecret(secret)) { throw new StripeSignatureVerificationError(header, payload, { message: 'No webhook secret value was provided. It should start with `whsec_`', @@ -304,7 +308,7 @@ export function createWebhooks( encodedHeader, this.EXPECTED_SCHEME ); - if (!secret || /^[ \t\r\n\f\v]*$/.test(secret)) { + if (isBlankWebhookSecret(secret)) { throw new StripeSignatureVerificationError(header, payload, { message: 'No webhook secret value was provided. It should start with `whsec_`', diff --git a/src/utils.ts b/src/utils.ts index 3efaf0a857..525bc0a8a1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -510,6 +510,10 @@ export function parsePayload( return JSON.parse(raw) as Record; } +export function isBlankWebhookSecret(secret: string): boolean { + return !secret || /^[ \t\r\n\f\v]*$/.test(secret); +} + export function maybeExtractFromCloudProviderEnvelope( payload: string | Uint8Array ): Record {