Skip to content

Commit

Permalink
Add webhook signing
Browse files Browse the repository at this point in the history
  • Loading branch information
jlomas-stripe committed May 9, 2017
1 parent 0309c43 commit b120ec5
Show file tree
Hide file tree
Showing 7 changed files with 364 additions and 0 deletions.
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,62 @@ charge.lastResponse.requestId // see: https://stripe.com/docs/api/node#request_i
charge.lastResponse.statusCode
```

### Webhook signing

Stripe can optionally sign the webhook events it sends to your endpoint, allowing you to validate that they were not sent by a third-party. You can read more about it [here](https://stripe.com/docs/webhooks#signatures).

Here's an example of how to use it with Express:

```js
const Express = require('express');
const bodyParser = require('body-parser')

const Stripe = require('stripe')('sk_test_...');

const webhookSecret = 'whsec_...';

// We use a router here so we can use a separate `bodyParser` instance that
// adds the raw POST data to the `request`
const router = Express.Router();

router.use(bodyParser.json({
verify: function(request, response, buffer) {
request.rawBody = buffer.toString();
},
}));

router.post('/webhooks', function(request, response) {
var event;

try {
// Try adding the Event as `request.event`
event = stripe.webhooks.constructEvent(
request.rawBody,
request.headers['stripe-signature'],
webhookSecret
);
} catch (e) {
// If `constructEvent` throws an error, respond with the message and return.
console.log('Error', e.message);

return response.status(400).send('Webhook Error:' + e.message);
}

console.log('Success', event.id);

// Event was 'constructed', so we can respond with a 200 OK
response.status(200).send('Signed Webhook Received: ' + event.id);
});

// You could either create this app, or just return the `Router` for use in an
// existing Express app - up to you!
const app = Express();
app.use(router);
app.listen(3000, function() {
console.log('Example app listening on port 3000!')
});
```

## More Information

* [REST API Version](https://github.com/stripe/stripe-node/wiki/REST-API-Version)
Expand Down
1 change: 1 addition & 0 deletions lib/Error.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ _Error.StripeAuthenticationError = StripeError.extend({type: 'StripeAuthenticati
_Error.StripePermissionError = StripeError.extend({type: 'StripePermissionError'});
_Error.StripeRateLimitError = StripeError.extend({type: 'StripeRateLimitError'});
_Error.StripeConnectionError = StripeError.extend({type: 'StripeConnectionError'});
_Error.StripeSignatureVerificationError = StripeError.extend({type: 'StripeSignatureVerificationError'});
107 changes: 107 additions & 0 deletions lib/Webhooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
var crypto = require('crypto');

var utils = require('./utils');
var Error = require('./Error');

var Webhook = {
DEFAULT_TOLERANCE: 300,

constructEvent: function(payload, header, secret, tolerance) {
var jsonPayload = JSON.parse(payload);

this.signature.verifyHeader(payload, header, secret, tolerance || Webhook.DEFAULT_TOLERANCE);

return jsonPayload;
},
};

var signature = {
EXPECTED_SCHEME: 'v1',

_computeSignature: function(payload, secret) {
return crypto.createHmac('sha256', secret)
.update(payload)
.digest('hex');
},

verifyHeader: function(payload, header, secret, tolerance) {
var details = parseHeader(header, this.EXPECTED_SCHEME);

if (!details || details.timestamp === -1) {
throw new Error.StripeSignatureVerificationError({
message: 'Unable to extract timestamp and signatures from header',
detail: {
header: header,
payload: payload,
},
});
}

if (!details.signatures.length) {
throw new Error.StripeSignatureVerificationError({
message: 'No signatures found with expected scheme',
detail: {
header: header,
payload: payload,
},
});
}

var expectedSignature = this._computeSignature(details.timestamp + '.' + payload, secret);

var signatureFound = !!details.signatures
.filter(utils.secureCompare.bind(utils, expectedSignature))
.length;

if (!signatureFound) {
throw new Error.StripeSignatureVerificationError({
message: 'No signatures found matching the expected signature for payload',
detail: {
header: header,
payload: payload,
},
});
}

var timestampAge = Math.floor(Date.now() / 1000) - details.timestamp;

if (tolerance > 0 && timestampAge > tolerance) {
throw new Error.StripeSignatureVerificationError({
message: 'Timestamp outside the tolerance zone',
detail: {
header: header,
payload: payload,
},
});
}

return true;
},
};

function parseHeader(header, scheme) {
if (typeof header !== 'string') {
return null;
}

return header.split(',').reduce(function(accum, item) {
var 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: [],
});
};

Webhook.signature = signature;

module.exports = Webhook;
2 changes: 2 additions & 0 deletions lib/stripe.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ function Stripe(key, version) {
this._prepResources();
this.setApiKey(key);
this.setApiVersion(version);

this.webhooks = require('./Webhooks');
}

Stripe.prototype = {
Expand Down
36 changes: 36 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

var qs = require('qs');
var crypto = require('crypto');

var hasOwn = {}.hasOwnProperty;
var isPlainObject = require('lodash.isplainobject');
Expand Down Expand Up @@ -124,4 +125,39 @@ var utils = module.exports = {
return arr;
},

/**
* Secure compare, from https://github.com/freewil/scmp
*/
secureCompare: function(a, b) {
var a = asBuffer(a);
var b = asBuffer(b);

// return early here if buffer lengths are not equal since timingSafeEqual
// will throw if buffer lengths are not equal
if (a.length !== b.length) {
return false;
}

// use crypto.timingSafeEqual if available (since Node.js v6.6.0),
// otherwise use our own scmp-internal function.
if (crypto.timingSafeEqual) {
return crypto.timingSafeEqual(a, b);
}

var len = a.length;
var result = 0;

for (var i = 0; i < len; ++i) {
result |= a[i] ^ b[i];
}
return result === 0;
},
};

function asBuffer(thing) {
if (Buffer.isBuffer(thing)) {
return thing;
}

return Buffer.from ? Buffer.from(thing) : new Buffer(thing);
}
148 changes: 148 additions & 0 deletions test/Webhook.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
'use strict';

var stripe = require('./testUtils').getSpyableStripe();
var expect = require('chai').expect;

var EVENT_PAYLOAD = {
id: 'evt_test_webhook',
object: 'event',
};
var EVENT_PAYLOAD_STRING = JSON.stringify(EVENT_PAYLOAD, null, 2);

var SECRET = 'whsec_test_secret';

describe('Webhooks', function() {
describe('.constructEvent', function() {
it('should return an Event instance from a valid JSON payload and valid signature header', function() {
var header = generateHeaderString({
payload: EVENT_PAYLOAD_STRING,
});

var event = stripe.webhooks.constructEvent(EVENT_PAYLOAD_STRING, header, SECRET);

expect(event.id).to.equal(EVENT_PAYLOAD.id);
});

it('should raise a JSON error from invalid JSON payload',
function() {
var header = generateHeaderString({
payload: '} I am not valid JSON; 123][',
});
expect(function() {
stripe.webhooks.constructEvent('} I am not valid JSON; 123][', header, SECRET);
}).to.throw(/Unexpected token/);
});

it('should raise a SignatureVerificationError from a valid JSON payload and an invalid signature header',
function() {
var header = 'bad_header';

expect(function() {
stripe.webhooks.constructEvent(EVENT_PAYLOAD_STRING, header, SECRET);
}).to.throw(/Unable to extract timestamp and signatures from header/);
});
});

describe('.verifySignatureHeader', function() {
it('should raise a SignatureVerificationError when the header does not have the expected format', function() {
var header = "I'm not even a real signature header";

var expectedMessage = /Unable to extract timestamp and signatures from header/;

expect(function() {
stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, header, SECRET);
}).to.throw(expectedMessage);

expect(function() {
stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, null, SECRET);
}).to.throw(expectedMessage);

expect(function() {
stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, undefined, SECRET);
}).to.throw(expectedMessage);

expect(function() {
stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, '', SECRET);
}).to.throw(expectedMessage);
});

it('should raise a SignatureVerificationError when there are no signatures with the expected scheme', function() {
var header = generateHeaderString({
scheme: 'v0',
});

expect(function() {
stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, header, SECRET);
}).to.throw(/No signatures found with expected scheme/);
});

it('should raise a SignatureVerificationError when there are no valid signatures for the payload', function() {
var header = generateHeaderString({
signature: 'bad_signature',
});

expect(function() {
stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, header, SECRET);
}).to.throw(/No signatures found matching the expected signature for payload/);
});

it('should raise a SignatureVerificationError when the timestamp is not within the tolerance', function() {
var header = generateHeaderString({
timestamp: (Date.now() / 1000) - 15,
});

expect(function() {
stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, header, SECRET, 10);
}).to.throw(/Timestamp outside the tolerance zone/);
});

it('should return true when the header contains a valid signature and ' +
'the timestamp is within the tolerance',
function() {
var header = generateHeaderString({
timestamp: (Date.now() / 1000),
});

expect(stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, header, SECRET, 10)).to.equal(true);
});

it('should return true when the header contains at least one valid signature', function() {
var header = generateHeaderString({
timestamp: (Date.now() / 1000),
});

header += ',v1=potato';

expect(stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, header, SECRET, 10)).to.equal(true);
});

it('should return true when the header contains a valid signature ' +
'and the timestamp is off but no tolerance is provided',
function() {
var header = generateHeaderString({
timestamp: 12345,
});

expect(stripe.webhooks.signature.verifyHeader(EVENT_PAYLOAD_STRING, header, SECRET)).to.equal(true);
});
});
});

function generateHeaderString(opts) {
opts = opts || {};

opts.timestamp = Math.floor(opts.timestamp) || Math.floor(Date.now() / 1000);
opts.payload = opts.payload || EVENT_PAYLOAD_STRING;
opts.secret = opts.secret || SECRET;
opts.scheme = opts.scheme || stripe.webhooks.signature.EXPECTED_SCHEME;

opts.signature = opts.signature ||
stripe.webhooks.signature._computeSignature(opts.timestamp + '.' + opts.payload, opts.secret);

var generatedHeader = [
't=' + opts.timestamp,
opts.scheme + '=' + opts.signature,
].join(',');

return generatedHeader;
}
Loading

0 comments on commit b120ec5

Please sign in to comment.