mirror of
https://github.com/jambonz/jambonz-node.git
synced 2025-12-19 05:17:49 +00:00
add functionality to verify signature of incoming webhook requests
This commit is contained in:
20
README.md
20
README.md
@@ -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, () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user