Files
jambonz-node/lib/jambonz/webhook-response.js
2023-02-15 17:30:18 +07:00

143 lines
3.6 KiB
JavaScript

const crypto = require('crypto');
const logger = { debug: require('debug')('jambonz:jambonz-node')};
const {validateVerb, specs } = require('verb-specifications');
const _specs = new Map(Object.entries(specs));
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, timestamp, secret) {
const data = Buffer.isBuffer(payload) ?
payload.toString('utf8') :
(typeof payload === 'object' ? JSON.stringify(payload) : payload);
return crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${data}`, 'utf8')
.digest('hex');
}
class WebhookResponse {
constructor() {
this.payload = [];
}
get length() {
return this.payload.length;
}
set length(len) {
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 in ${req.url}`);
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 secrets = Array.isArray(secret) ? secret : [secret];
let signatureFound = false;
for (const secret of secrets) {
const expectedSignature = computeSignature(req.body, details.timestamp, secret);
signatureFound = details.signatures.filter(
secureCompare.bind(null, expectedSignature)
).length > 0;
if (signatureFound) break;
}
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;
}
addVerb(verb, payload) {
validateVerb(verb, payload, logger);
this.payload.push({verb: verb.replace('_', ':'), ...payload});
return this;
}
}
for (const [verb] of _specs) {
WebhookResponse.prototype[verb.replace(':', '_')] = function(payload) {
return WebhookResponse.prototype.addVerb.call(this, verb, payload);
};
}
module.exports = WebhookResponse;