UNPKG

stripe

Version:
265 lines 14.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createWebhooks = void 0; const Error_js_1 = require("./Error.js"); const CryptoProvider_js_1 = require("./crypto/CryptoProvider.js"); function createWebhooks(platformFunctions) { const Webhook = { DEFAULT_TOLERANCE: 300, signature: null, constructEvent(payload, header, secret, tolerance, cryptoProvider, receivedAt) { try { if (!this.signature) { throw new Error('ERR: missing signature helper, unable to verify'); } cryptoProvider = cryptoProvider || getCryptoProvider(); this.signature.verifyHeader(payload, header, secret, tolerance || Webhook.DEFAULT_TOLERANCE, cryptoProvider, receivedAt); } catch (e) { if (e instanceof CryptoProvider_js_1.CryptoProviderOnlySupportsAsyncError) { e.message += '\nUse `await constructEventAsync(...)` instead of `constructEvent(...)`'; } throw e; } const jsonPayload = payload instanceof Uint8Array ? JSON.parse(new TextDecoder('utf8').decode(payload)) : JSON.parse(payload); if (jsonPayload && jsonPayload.object === 'v2.core.event') { throw new Error('You passed an event notification to stripe.webhooks.constructEvent, which expects a webhook payload. Use stripe.parseEventNotification instead.'); } return jsonPayload; }, async constructEventAsync(payload, header, secret, tolerance, cryptoProvider, receivedAt) { if (!this.signature) { throw new Error('ERR: missing signature helper, unable to verify'); } cryptoProvider = cryptoProvider || getCryptoProvider(); await this.signature.verifyHeaderAsync(payload, header, secret, tolerance || Webhook.DEFAULT_TOLERANCE, cryptoProvider, receivedAt); const jsonPayload = payload instanceof Uint8Array ? JSON.parse(new TextDecoder('utf8').decode(payload)) : JSON.parse(payload); if (jsonPayload && jsonPayload.object === 'v2.core.event') { throw new Error('You passed an event notification to stripe.webhooks.constructEvent, which expects a webhook payload. Use stripe.parseEventNotificationAsync instead.'); } return jsonPayload; }, /** * Generates a header to be used for webhook mocking * * @typedef {object} opts * @property {number} timestamp - Timestamp of the header. Defaults to Date.now() * @property {string} payload - JSON stringified payload object, containing the 'id' and 'object' parameters * @property {string} secret - Stripe webhook secret 'whsec_...' * @property {string} scheme - Version of API to hit. Defaults to 'v1'. * @property {string} signature - Computed webhook signature * @property {CryptoProvider} cryptoProvider - Crypto provider to use for computing the signature if none was provided. Defaults to NodeCryptoProvider. */ generateTestHeaderString: function (opts) { const preparedOpts = prepareOptions(opts); const signature = preparedOpts.signature || preparedOpts.cryptoProvider.computeHMACSignature(preparedOpts.payloadString, preparedOpts.secret); return preparedOpts.generateHeaderString(signature); }, generateTestHeaderStringAsync: async function (opts) { const preparedOpts = prepareOptions(opts); const signature = preparedOpts.signature || (await preparedOpts.cryptoProvider.computeHMACSignatureAsync(preparedOpts.payloadString, preparedOpts.secret)); return preparedOpts.generateHeaderString(signature); }, }; const signature = { EXPECTED_SCHEME: 'v1', verifyHeader(encodedPayload, encodedHeader, secret, tolerance, cryptoProvider, receivedAt) { const { decodedHeader: header, decodedPayload: payload, details, suspectPayloadType, } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); const secretContainsWhitespace = /\s/.test(secret); cryptoProvider = cryptoProvider || getCryptoProvider(); const expectedSignature = cryptoProvider.computeHMACSignature(makeHMACContent(payload, details), secret); /** * TODO(MAJOR): https://go/j/DEVSDK-3087 * Passing in 0 by default skips timestamp tolerance verifications. Although it is mostly used in test, * we should change the default behavior to pass DEFAULT_TOLERANCE instead of 0 in the next major. */ validateComputedSignature(payload, header, details, expectedSignature, tolerance || 0, suspectPayloadType, secretContainsWhitespace, receivedAt); return true; }, async verifyHeaderAsync(encodedPayload, encodedHeader, secret, tolerance, cryptoProvider, receivedAt) { const { decodedHeader: header, decodedPayload: payload, details, suspectPayloadType, } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); const secretContainsWhitespace = /\s/.test(secret); cryptoProvider = cryptoProvider || getCryptoProvider(); const expectedSignature = await cryptoProvider.computeHMACSignatureAsync(makeHMACContent(payload, details), secret); /** * TODO(MAJOR): https://go/j/DEVSDK-3087 * Passing in 0 by default skips timestamp tolerance verifications. Although it is mostly used in test, * we should change the default behavior to pass DEFAULT_TOLERANCE instead of 0 in the next major. */ return validateComputedSignature(payload, header, details, expectedSignature, tolerance || 0, suspectPayloadType, secretContainsWhitespace, receivedAt); }, }; function makeHMACContent(payload, details) { return `${details.timestamp}.${payload}`; } function parseEventDetails(encodedPayload, encodedHeader, expectedScheme) { // Express's type for `Request#headers` is `string | []string` // which is because the `set-cookie` header is an array, // but no other headers are an array (docs: https://nodejs.org/api/http.html#http_message_headers) // (Express's Request class is an extension of http.IncomingMessage, and doesn't appear to be relevantly modified: https://github.com/expressjs/express/blob/master/lib/request.js#L31) 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 (!encodedPayload) { throw new Error_js_1.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) : encodedPayload; if (encodedHeader == null || encodedHeader == '') { throw new Error_js_1.StripeSignatureVerificationError(encodedHeader, encodedPayload, { message: 'No stripe-signature header value was provided.', }); } const decodedHeader = encodedHeader instanceof Uint8Array ? textDecoder.decode(encodedHeader) : encodedHeader; const details = parseHeader(decodedHeader, expectedScheme); if (!details || details.timestamp === -1) { throw new Error_js_1.StripeSignatureVerificationError(decodedHeader, decodedPayload, { message: 'Unable to extract timestamp and signatures from header', }); } if (!details.signatures.length) { throw new Error_js_1.StripeSignatureVerificationError(decodedHeader, decodedPayload, { message: 'No signatures found with expected scheme', }); } return { decodedPayload, decodedHeader, details, suspectPayloadType, }; } /** * Validates that at least one signature in the parsed header matches the * expected signature, and that the event timestamp is within the allowed * {@link tolerance} window (in seconds). Set `tolerance` to `0` to skip * timestamp verification. * * TODO(MAJOR): https://go/j/DEVSDK-3087 - Change this default behavior to use DEFAULT_TOLERANCE instead of 0. * By default, validateComputedSignature doesn't perform timestamp verification. * * This method is mostly meant for tests or offline processing where the delivery time * of the event isn't important. * Integrations that process webhooks as they come in should use constructEvent method instead. * * @param payload The decoded webhook payload string. * @param header The decoded `stripe-signature` header value. * @param details Parsed header containing timestamp and signatures. * @param expectedSignature HMAC signature computed from the payload and secret. * @param tolerance Maximum allowed age of the event in seconds. Use 0 to skip timestamp tolerance verification. * @param suspectPayloadType Whether the payload was not a string or Buffer. * @param secretContainsWhitespace Whether the signing secret contains whitespace. * @param receivedAt - Timestamp for age calculation * @returns `true` if the signature and timestamp are valid. * * @throws {StripeSignatureVerificationError} If verification fails. */ function validateComputedSignature(payload, header, details, expectedSignature, tolerance, suspectPayloadType, secretContainsWhitespace, receivedAt) { const signatureFound = !!details.signatures.filter(platformFunctions.secureCompare.bind(platformFunctions, expectedSignature)).length; const docsLocation = '\nLearn more about webhook signing and explore webhook integration examples for various frameworks at ' + 'https://docs.stripe.com/webhooks/signature'; const whitespaceMessage = secretContainsWhitespace ? '\n\nNote: The provided signing secret contains whitespace. This often indicates an extra newline or space is in the value' : ''; if (!signatureFound) { if (suspectPayloadType) { throw new Error_js_1.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' + docsLocation + '\n' + whitespaceMessage, }); } throw new Error_js_1.StripeSignatureVerificationError(header, payload, { message: 'No signatures found matching the expected signature for payload.' + ' Are you passing the raw request body you received from Stripe? \n' + ' If a webhook request is being forwarded by a third-party tool,' + ' ensure that the exact request body, including JSON formatting and new line style, is preserved.\n' + docsLocation + '\n' + whitespaceMessage, }); } const timestampAge = Math.floor((typeof receivedAt === 'number' ? receivedAt : Date.now()) / 1000) - details.timestamp; if (tolerance > 0 && timestampAge > tolerance) { throw new Error_js_1.StripeSignatureVerificationError(header, payload, { message: 'Timestamp outside the tolerance zone', }); } return true; } function parseHeader(header, scheme) { if (typeof header !== 'string') { return null; } scheme = scheme || signature.EXPECTED_SCHEME; return header.split(',').reduce((accum, item) => { const kv = item.split('='); if (kv[0] === 't') { accum.timestamp = parseInt(kv[1], 10); } if (kv[0] === scheme) { accum.signatures.push(kv[1]); } return accum; }, { timestamp: -1, signatures: [], }); } let webhooksCryptoProviderInstance = null; /** * Lazily instantiate a CryptoProvider instance. This is a stateless object * so a singleton can be used here. */ function getCryptoProvider() { if (!webhooksCryptoProviderInstance) { webhooksCryptoProviderInstance = platformFunctions.createDefaultCryptoProvider(); } return webhooksCryptoProviderInstance; } function prepareOptions(opts) { if (!opts) { throw new Error_js_1.StripeError({ message: 'Options are required', }); } const timestamp = (opts.timestamp && Math.floor(opts.timestamp)) || Math.floor(Date.now() / 1000); const scheme = opts.scheme || signature.EXPECTED_SCHEME; const cryptoProvider = opts.cryptoProvider || getCryptoProvider(); const payloadString = `${timestamp}.${opts.payload}`; const generateHeaderString = (signature) => { return `t=${timestamp},${scheme}=${signature}`; }; return { ...opts, timestamp, scheme, cryptoProvider, payloadString, generateHeaderString, }; } Webhook.signature = signature; return Webhook; } exports.createWebhooks = createWebhooks; //# sourceMappingURL=Webhooks.js.map