diff --git a/README.md b/README.md index 22c53d24b8..c262c901c1 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Stripe can optionally sign the webhook events it sends to your endpoint, allowin Please note that you must pass the _raw_ request body, exactly as received from Stripe, to the `constructEvent()` function; this will not work with a parsed (i.e., JSON) request body. -You can find an example of how to use this with [Express](https://expressjs.com/) in the [`examples/webhook-signing`](examples/webhook-signing) folder, but here's what it looks like: +You can find an example of how to use this with various JavaScript frameworks in [`examples/webhook-signing`](examples/webhook-signing) folder, but here's what it looks like: ```js const event = stripe.webhooks.constructEvent( diff --git a/lib/Webhooks.js b/lib/Webhooks.js index 683c7eab5e..13ddb5c0a4 100644 --- a/lib/Webhooks.js +++ b/lib/Webhooks.js @@ -54,23 +54,30 @@ function createWebhooks(platformFunctions) { const signature = { EXPECTED_SCHEME: 'v1', verifyHeader(encodedPayload, encodedHeader, secret, tolerance, cryptoProvider) { - const { decodedHeader: header, decodedPayload: payload, details, } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); + const { decodedHeader: header, decodedPayload: payload, details, suspectPayloadType, } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); cryptoProvider = cryptoProvider || getCryptoProvider(); const expectedSignature = cryptoProvider.computeHMACSignature(makeHMACContent(payload, details), secret); - validateComputedSignature(payload, header, details, expectedSignature, tolerance); + validateComputedSignature(payload, header, details, expectedSignature, tolerance, suspectPayloadType); return true; }, async verifyHeaderAsync(encodedPayload, encodedHeader, secret, tolerance, cryptoProvider) { - const { decodedHeader: header, decodedPayload: payload, details, } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); + const { decodedHeader: header, decodedPayload: payload, details, suspectPayloadType, } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); cryptoProvider = cryptoProvider || getCryptoProvider(); const expectedSignature = await cryptoProvider.computeHMACSignatureAsync(makeHMACContent(payload, details), secret); - return validateComputedSignature(payload, header, details, expectedSignature, tolerance); + return validateComputedSignature(payload, header, details, expectedSignature, tolerance, suspectPayloadType); }, }; function makeHMACContent(payload, details) { return `${details.timestamp}.${payload}`; } function parseEventDetails(encodedPayload, encodedHeader, expectedScheme) { + if (!encodedPayload) { + throw new StripeSignatureVerificationError(encodedHeader, encodedPayload, { + message: 'No webhook payload was provided.', + }); + } + const suspectPayloadType = typeof encodedPayload != 'string' && + !(encodedPayload instanceof Uint8Array); const textDecoder = new TextDecoder('utf8'); const decodedPayload = encodedPayload instanceof Uint8Array ? textDecoder.decode(encodedPayload) @@ -82,6 +89,11 @@ function createWebhooks(platformFunctions) { if (Array.isArray(encodedHeader)) { throw new Error('Unexpected: An array was passed as a header, which should not be possible for the stripe-signature header.'); } + if (encodedHeader == null || encodedHeader == '') { + throw new StripeSignatureVerificationError(encodedHeader, encodedPayload, { + message: 'No stripe-signature header value was provided.', + }); + } const decodedHeader = encodedHeader instanceof Uint8Array ? textDecoder.decode(encodedHeader) : encodedHeader; @@ -100,16 +112,26 @@ function createWebhooks(platformFunctions) { decodedPayload, decodedHeader, details, + suspectPayloadType, }; } - function validateComputedSignature(payload, header, details, expectedSignature, tolerance) { + function validateComputedSignature(payload, header, details, expectedSignature, tolerance, suspectPayloadType) { const signatureFound = !!details.signatures.filter(platformFunctions.secureCompare.bind(platformFunctions, expectedSignature)).length; if (!signatureFound) { - // @ts-ignore + if (suspectPayloadType) { + throw new StripeSignatureVerificationError(header, payload, { + message: 'Webhook payload must be provided as a string or a Buffer (https://nodejs.org/api/buffer.html) instance representing the _raw_ request body.' + + 'Payload was provided as a parsed JavaScript object instead. \n' + + 'Signature verification is impossible without access to the original signed material. \n' + + 'Learn more about webhook signing and explore webhook integration examples for various frameworks at ' + + 'https://github.com/stripe/stripe-node#webhook-signing', + }); + } throw new StripeSignatureVerificationError(header, payload, { message: 'No signatures found matching the expected signature for payload.' + - ' Are you passing the raw request body you received from Stripe?' + - ' https://github.com/stripe/stripe-node#webhook-signing', + ' Are you passing the raw request body you received from Stripe? \n' + + 'Learn more about webhook signing and explore webhook integration examples for various frameworks at ' + + 'https://github.com/stripe/stripe-node#webhook-signing', }); } const timestampAge = Math.floor(Date.now() / 1000) - details.timestamp; diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 8c1ceced52..bc6977b75e 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -12,6 +12,7 @@ type WebhookParsedEvent = { details: WebhookParsedHeader; decodedPayload: WebhookHeader; decodedHeader: WebhookPayload; + suspectPayloadType: boolean; }; type WebhookTestHeaderOptions = { timestamp: number; @@ -163,6 +164,7 @@ function createWebhooks(platformFunctions: PlatformFunctions): WebhookObject { decodedHeader: header, decodedPayload: payload, details, + suspectPayloadType, } = parseEventDetails( encodedPayload, encodedHeader, @@ -180,7 +182,8 @@ function createWebhooks(platformFunctions: PlatformFunctions): WebhookObject { header, details, expectedSignature, - tolerance + tolerance, + suspectPayloadType ); return true; @@ -197,6 +200,7 @@ function createWebhooks(platformFunctions: PlatformFunctions): WebhookObject { decodedHeader: header, decodedPayload: payload, details, + suspectPayloadType, } = parseEventDetails( encodedPayload, encodedHeader, @@ -215,7 +219,8 @@ function createWebhooks(platformFunctions: PlatformFunctions): WebhookObject { header, details, expectedSignature, - tolerance + tolerance, + suspectPayloadType ); }, }; @@ -232,6 +237,20 @@ function createWebhooks(platformFunctions: PlatformFunctions): WebhookObject { encodedHeader: WebhookHeader, expectedScheme: string ): WebhookParsedEvent { + if (!encodedPayload) { + throw new StripeSignatureVerificationError( + encodedHeader, + encodedPayload, + { + message: 'No webhook payload was provided.', + } + ); + } + + const suspectPayloadType = + typeof encodedPayload != 'string' && + !(encodedPayload instanceof Uint8Array); + const textDecoder = new TextDecoder('utf8'); const decodedPayload = encodedPayload instanceof Uint8Array @@ -248,6 +267,16 @@ function createWebhooks(platformFunctions: PlatformFunctions): WebhookObject { ); } + if (encodedHeader == null || encodedHeader == '') { + throw new StripeSignatureVerificationError( + encodedHeader, + encodedPayload, + { + message: 'No stripe-signature header value was provided.', + } + ); + } + const decodedHeader = encodedHeader instanceof Uint8Array ? textDecoder.decode(encodedHeader) @@ -279,6 +308,7 @@ function createWebhooks(platformFunctions: PlatformFunctions): WebhookObject { decodedPayload, decodedHeader, details, + suspectPayloadType, }; } @@ -287,19 +317,30 @@ function createWebhooks(platformFunctions: PlatformFunctions): WebhookObject { header: WebhookHeader, details: WebhookParsedHeader, expectedSignature: string, - tolerance: number + tolerance: number, + suspectPayloadType: boolean ): boolean { const signatureFound = !!details.signatures.filter( platformFunctions.secureCompare.bind(platformFunctions, expectedSignature) ).length; if (!signatureFound) { - // @ts-ignore + if (suspectPayloadType) { + throw new StripeSignatureVerificationError(header, payload, { + message: + 'Webhook payload must be provided as a string or a Buffer (https://nodejs.org/api/buffer.html) instance representing the _raw_ request body.' + + 'Payload was provided as a parsed JavaScript object instead. \n' + + 'Signature verification is impossible without access to the original signed material. \n' + + 'Learn more about webhook signing and explore webhook integration examples for various frameworks at ' + + 'https://github.com/stripe/stripe-node#webhook-signing', + }); + } throw new StripeSignatureVerificationError(header, payload, { message: 'No signatures found matching the expected signature for payload.' + - ' Are you passing the raw request body you received from Stripe?' + - ' https://github.com/stripe/stripe-node#webhook-signing', + ' Are you passing the raw request body you received from Stripe? \n' + + 'Learn more about webhook signing and explore webhook integration examples for various frameworks at ' + + 'https://github.com/stripe/stripe-node#webhook-signing', }); } diff --git a/test/Webhook.spec.ts b/test/Webhook.spec.ts index 6ca6a4836b..b14ce753f3 100644 --- a/test/Webhook.spec.ts +++ b/test/Webhook.spec.ts @@ -104,6 +104,21 @@ describe('Webhooks', () => { ); }); + it('should raise a SignatureVerificationError from a valid JSON payload and an invalid signature header', async () => { + const header = 'bad_header'; + const expected = /No webhook payload was provided/; + + await expect(constructEventFn(null, header, SECRET)).to.be.rejectedWith( + expected + ); + await expect( + constructEventFn(undefined, header, SECRET) + ).to.be.rejectedWith(expected); + await expect(constructEventFn('', header, SECRET)).to.be.rejectedWith( + expected + ); + }); + it('should error if you pass a signature which is an array, even though our types say you can', async () => { const header = stripe.webhooks.generateTestHeaderString({ payload: EVENT_PAYLOAD_STRING, @@ -164,6 +179,10 @@ describe('Webhooks', () => { await expect( verifyHeaderFn(EVENT_PAYLOAD_STRING, header, SECRET) ).to.be.rejectedWith(StripeSignatureVerificationError, expectedMessage); + }); + + it('should raise a SignatureVerificationError when the header is null or empty', async () => { + const expectedMessage = /No stripe-signature header value was provided./; await expect( verifyHeaderFn(EVENT_PAYLOAD_STRING, null, SECRET) @@ -286,6 +305,24 @@ describe('Webhooks', () => { ).to.equal(true); }); + it('should raise a SignatureVerificationError when payload is of an unknown type', async () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + }); + + await expect(verifyHeaderFn({}, header, SECRET)).to.be.rejectedWith( + StripeSignatureVerificationError, + /Webhook payload must be provided as a string or a Buffer/ + ); + await expect( + verifyHeaderFn(new Date(), header, SECRET) + ).to.be.rejectedWith( + StripeSignatureVerificationError, + /Webhook payload must be provided as a string or a Buffer/ + ); + }); + describe('custom CryptoProvider', () => { const cryptoProvider = new FakeCryptoProvider();