'use strict'; const utils = require('./utils'); const {StripeError, StripeSignatureVerificationError} = require('./Error'); const Webhook = { DEFAULT_TOLERANCE: 300, // 5 minutes constructEvent(payload, header, secret, tolerance, cryptoProvider) { this.signature.verifyHeader( payload, header, secret, tolerance || Webhook.DEFAULT_TOLERANCE, cryptoProvider ); const jsonPayload = JSON.parse(payload); return jsonPayload; }, async constructEventAsync( payload, header, secret, tolerance, cryptoProvider ) { await this.signature.verifyHeaderAsync( payload, header, secret, tolerance || Webhook.DEFAULT_TOLERANCE, cryptoProvider ); const jsonPayload = JSON.parse(payload); 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) { if (!opts) { throw new StripeError({ message: 'Options are required', }); } opts.timestamp = Math.floor(opts.timestamp) || Math.floor(Date.now() / 1000); opts.scheme = opts.scheme || signature.EXPECTED_SCHEME; opts.cryptoProvider = opts.cryptoProvider || getNodeCryptoProvider(); opts.signature = opts.signature || opts.cryptoProvider.computeHMACSignature( opts.timestamp + '.' + opts.payload, opts.secret ); const generatedHeader = [ 't=' + opts.timestamp, opts.scheme + '=' + opts.signature, ].join(','); return generatedHeader; }, }; const signature = { EXPECTED_SCHEME: 'v1', verifyHeader( encodedPayload, encodedHeader, secret, tolerance, cryptoProvider ) { const { decodedHeader: header, decodedPayload: payload, details, } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); cryptoProvider = cryptoProvider || getNodeCryptoProvider(); const expectedSignature = cryptoProvider.computeHMACSignature( makeHMACContent(payload, details), secret ); validateComputedSignature( payload, header, details, expectedSignature, tolerance ); return true; }, async verifyHeaderAsync( encodedPayload, encodedHeader, secret, tolerance, cryptoProvider ) { const { decodedHeader: header, decodedPayload: payload, details, } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); cryptoProvider = cryptoProvider || getNodeCryptoProvider(); const expectedSignature = await cryptoProvider.computeHMACSignatureAsync( makeHMACContent(payload, details), secret ); return validateComputedSignature( payload, header, details, expectedSignature, tolerance ); }, }; function makeHMACContent(payload, details) { return `${details.timestamp}.${payload}`; } function parseEventDetails(encodedPayload, encodedHeader, expectedScheme) { const decodedPayload = Buffer.isBuffer(encodedPayload) ? encodedPayload.toString('utf8') : encodedPayload; // 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.' ); } const decodedHeader = Buffer.isBuffer(encodedHeader) ? encodedHeader.toString('utf8') : encodedHeader; const details = parseHeader(decodedHeader, expectedScheme); if (!details || details.timestamp === -1) { throw new StripeSignatureVerificationError({ message: 'Unable to extract timestamp and signatures from header', detail: { decodedHeader, decodedPayload, }, }); } if (!details.signatures.length) { throw new StripeSignatureVerificationError({ message: 'No signatures found with expected scheme', detail: { decodedHeader, decodedPayload, }, }); } return { decodedPayload, decodedHeader, details, }; } function validateComputedSignature( payload, header, details, expectedSignature, tolerance ) { const signatureFound = !!details.signatures.filter( utils.secureCompare.bind(utils, expectedSignature) ).length; if (!signatureFound) { throw new StripeSignatureVerificationError({ 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', detail: { header, payload, }, }); } const timestampAge = Math.floor(Date.now() / 1000) - details.timestamp; if (tolerance > 0 && timestampAge > tolerance) { throw new StripeSignatureVerificationError({ message: 'Timestamp outside the tolerance zone', detail: { header, payload, }, }); } return true; } function parseHeader(header, scheme) { if (typeof header !== 'string') { return null; } return header.split(',').reduce( (accum, item) => { const kv = item.split('='); if (kv[0] === 't') { accum.timestamp = kv[1]; } if (kv[0] === scheme) { accum.signatures.push(kv[1]); } return accum; }, { timestamp: -1, signatures: [], } ); } let webhooksNodeCryptoProviderInstance = null; /** * Lazily instantiate a NodeCryptoProvider instance. This is a stateless object * so a singleton can be used here. */ function getNodeCryptoProvider() { if (!webhooksNodeCryptoProviderInstance) { const NodeCryptoProvider = require('./crypto/NodeCryptoProvider'); webhooksNodeCryptoProviderInstance = new NodeCryptoProvider(); } return webhooksNodeCryptoProviderInstance; } Webhook.signature = signature; module.exports = Webhook;