add functionality to verify signature of incoming webhook requests

This commit is contained in:
Dave Horton
2021-03-24 14:11:18 -04:00
parent d383629ba4
commit 93b29f51d8
2 changed files with 113 additions and 10 deletions

View File

@@ -13,16 +13,16 @@ app.use(express.json());
app.post('/my-app', (req, res) => {
const jambonz = new WebhookResponse();
jambonz
.pause({length: 1.5})
.say({
text: 'Good morning. This is a simple test of text to speech functionality. That is all. Goodbye',
synthesizer: {
vendor: 'google',
language: 'en-US'
}
});
res.status(200).json(app);
jambonz
.pause({length: 1.5})
.say({
text: 'Good morning. This is a simple test of text to speech functionality. That is all. Goodbye',
synthesizer: {
vendor: 'google',
language: 'en-US'
}
});
res.status(200).json(app);
});
app.listen(port, () => {

View File

@@ -1,4 +1,72 @@
const crypto = require('crypto');
const {validate, specs} = require('../utils');
const EXPECTED_SCHEME = 'v1';
const DEFAULT_TOLERANCE = 300; // 5 minutes
/**
* Secure compare, from https://github.com/freewil/scmp
*/
function secureCompare(a, b) {
a = Buffer.from(a);
b = Buffer.from(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);
}
const len = a.length;
let result = 0;
for (let i = 0; i < len; ++i) {
result |= a[i] ^ b[i];
}
return result === 0;
}
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: [],
}
);
}
function computeSignature(payload, secret) {
const data = Buffer.isBuffer(payload) ?
payload.toString('utf8') :
(typeof payload === 'object' ? JSON.stringify(payload) : payload);
return crypto
.createHmac('sha256', secret)
.update(data, 'utf8')
.digest('hex');
}
class WebhookResponse {
constructor() {
@@ -14,6 +82,41 @@ class WebhookResponse {
this.payload.length = len;
}
/**
* returns a middleware function that can be used to verify
* the incoming request was signed by jambonz
* @param {string} secret - webhook signing secret
* @param {object} opts - opts
* @returns function
*/
static verifyJambonzSignature(secret) {
return (req, res, next) => {
const header = req.get('Jambonz-Signature');
if (!header) throw new Error('missing Jambonz-Signature');
const details = parseHeader(header, EXPECTED_SCHEME);
if (!details || details.timestamp === -1) {
throw new Error('unable to extract timestamp and signatures from header');
}
if (!details.signatures.length) {
throw new Error('no signatures found');
}
const expectedSignature = computeSignature(req.body, details.timestamp, secret);
const signatureFound = details.signatures.filter(
secureCompare.bind(null, expectedSignature)
).length > 0;
if (!signatureFound) {
throw new Error('No matching signatures found');
}
const timestampAge = Math.floor(Date.now() / 1000) - details.timestamp;
if (timestampAge > DEFAULT_TOLERANCE) {
throw new Error('timestamp outside of tolerance');
}
next();
};
}
toJSON() {
return this.payload;
}