UNPKG

@signalwire/compatibility-api

Version:
309 lines (268 loc) 10.7 kB
'use strict'; var crypto = require('crypto'); var _ = require('lodash'); var scmp = require('scmp'); var urllib = require('url'); var Url = require('url-parse'); /** * Utility function to construct the URL string, since Node.js url library won't include standard port numbers * * @param {Url} parsedUrl - The parsed url object that Twilio requested on your server * @returns {string} - URL with standard port number included */ function buildUrlWithStandardPort(parsedUrl) { let url = ''; const port = parsedUrl.protocol === 'https:' ? ':443' : ':80'; url += parsedUrl.protocol ? parsedUrl.protocol + '//' : ''; url += parsedUrl.username; url += parsedUrl.password ? ':' + parsedUrl.password : ''; url += (parsedUrl.username || parsedUrl.password) ? '@' : ''; url += parsedUrl.host ? parsedUrl.host + port : ''; url += parsedUrl.pathname + parsedUrl.query + parsedUrl.hash; return url; } /** Utility function to add a port number to a URL @param {Url} parsedUrl - The parsed url object that Twilio requested on your server @returns {string} - URL with port */ function addPort(parsedUrl) { if (!parsedUrl.port) { return buildUrlWithStandardPort(parsedUrl); } return parsedUrl.toString(); } /** Utility function to remove a port number from a URL @param {Url} parsedUrl - The parsed url object that Twilio requested on your server @returns {string} - URL without port */ function removePort(parsedUrl) { parsedUrl.set('port', ''); return parsedUrl.toString(); } /** Utility function to convert request parameter to a string format @param {string} paramName - The request parameter name @param {string|array<string>} paramValue - The request parameter value @returns {string} - Formatted parameter string */ function toFormUrlEncodedParam(paramName, paramValue) { if (paramValue instanceof Array) { return paramValue .map(val => toFormUrlEncodedParam(paramName, val)) .reduce((acc, val) => acc + val, ''); } return paramName + paramValue; } /** Utility function to get the expected signature for a given request @param {string} authToken - The auth token, as seen in the Twilio portal @param {string} url - The full URL (with query string) you configured to handle this request @param {object} params - the parameters sent with this request @returns {string} - signature */ function getExpectedTwilioSignature(authToken, url, params) { if (url.indexOf('bodySHA256') !== -1 && params === null) { params = {}; } var data = Object.keys(params) .sort() .reduce((acc, key) => acc + toFormUrlEncodedParam(key, params[key]), url); return crypto .createHmac('sha1', authToken) .update(Buffer.from(data, 'utf-8')) .digest('base64'); } /** Utility function to get the expected body hash for a given request's body @param {string} body - The plain-text body of the request */ function getExpectedBodyHash(body) { return crypto .createHash('sha256') .update(Buffer.from(body, 'utf-8')) .digest('hex'); } /** Utility function to validate an incoming request is indeed from Twilio @param {string} authToken - The auth token, as seen in the Twilio portal @param {string} header - The value of the X-Twilio-Signature or X-SignalWire-Signature header from the request @param {string} url - The full URL (with query string) you configured to handle this request @param {object} params - the parameters sent with this request @returns {boolean} - valid */ function validateRequest(authToken, twilioHeader, url, params, suppressWarning) { twilioHeader = twilioHeader || ''; const urlObject = new Url(url); const urlWithPort = addPort(urlObject); const urlWithoutPort = removePort(urlObject); /* * Check signature of the url with and without the port number * since signature generation on the back end is inconsistent */ const signatureWithPort = getExpectedTwilioSignature(authToken, urlWithPort, params); const signatureWithoutPort = getExpectedTwilioSignature(authToken, urlWithoutPort, params); const validSignatureWithPort = scmp(Buffer.from(twilioHeader), Buffer.from(signatureWithPort)); const validSignatureWithoutPort = scmp(Buffer.from(twilioHeader), Buffer.from(signatureWithoutPort)); const isValid = validSignatureWithoutPort || validSignatureWithPort if (!isValid && suppressWarning !== true) { const { project_id, call_id, call, message_id } = params || {} if (project_id || call_id || message_id || call?.project_id) { console.warn('It seems that you are trying to validate a RELAY request using the Compatibility SDK "validateRequest" method. Please use the `WebAPI.validateRequest()` method from the `@signalwire/node` SDK to validate all types of SignalWire requests (RELAY and Compatibility).') } } return isValid; } function validateBody(body, bodyHash) { var expectedHash = getExpectedBodyHash(body); return scmp(Buffer.from(bodyHash), Buffer.from(expectedHash)); } /** Utility function to validate an incoming request is indeed from Twilio. This also validates the request body against the bodySHA256 post parameter. @param {string} authToken - The auth token, as seen in the Twilio portal @param {string} header - The value of the X-Twilio-Signature or X-SignalWire-Signature header from the request @param {string} url - The full URL (with query string) you configured to handle this request @param {string} body - The body of the request @returns {boolean} - valid */ function validateRequestWithBody(authToken, twilioHeader, url, body) { const urlObject = new Url(url, true); return validateRequest(authToken, twilioHeader, url, {}) && validateBody(body, urlObject.query.bodySHA256); } /** Utility function to validate an incoming request is indeed from Twilio (for use with express). adapted from https://github.com/crabasa/twiliosig @param {object} request - An expressjs request object (http://expressjs.com/api.html#req.params) @param {string} authToken - The auth token, as seen in the Twilio portal @param {object} opts - options for request validation: - url: The full URL (with query string) you used to configure the webhook with Twilio - overrides host/protocol options - host: manually specify the host name used by Twilio in a number's webhook config - protocol: manually specify the protocol used by Twilio in a number's webhook config */ function validateExpressRequest(request, authToken, opts) { var options = opts || {}; var webhookUrl; if (options.url) { // Let the user specify the full URL webhookUrl = options.url; } else { // Use configured host/protocol, or infer based on request var protocol = options.protocol || request.protocol; var host = options.host || request.headers.host; webhookUrl = urllib.format({ protocol: protocol, host: host, pathname: request.originalUrl, }); if (request.originalUrl.search(/\?/) >= 0) { webhookUrl = webhookUrl.replace(/%3F/g, '?'); } } const reqHeaderSignature = request.header('X-SignalWire-Signature') || request.header('X-Twilio-Signature'); if (webhookUrl.indexOf('bodySHA256') > 0) { return validateRequestWithBody( authToken, reqHeaderSignature, webhookUrl, request.rawBody || '{}' ); } else { return validateRequest( authToken, reqHeaderSignature, webhookUrl, request.body || {} ); } } /** Express middleware to accompany a Twilio webhook. Provides Twilio request validation, and makes the response a little more friendly for our TwiML generator. Request validation requires the express.urlencoded middleware to have been applied (e.g. app.use(express.urlencoded()); in your app config). Options: - validate: {Boolean} whether or not the middleware should validate the request came from Twilio. Default true. If the request does not originate from Twilio, we will return a text body and a 403. If there is no configured auth token and validate=true, this is an error condition, so we will return a 500. - host: manually specify the host name used by Twilio in a number's webhook config - protocol: manually specify the protocol used by Twilio in a number's webhook config - url: The full URL (with query string) you used to configure the webhook with Twilio - overrides host/protocol options Returns a middleware function. Examples: var webhookMiddleware = twilio.webhook(); var webhookMiddleware = twilio.webhook('asdha9dhjasd'); //init with auth token var webhookMiddleware = twilio.webhook({ validate:false // don't attempt request validation }); var webhookMiddleware = twilio.webhook({ host: 'hook.twilio.com', protocol: 'https' }); */ function webhook() { var opts = { validate: true, }; // Process arguments var tokenString; for (var i = 0, l = arguments.length; i < l; i++) { var arg = arguments[i]; if (typeof arg === 'string') { tokenString = arg; } else { opts = _.extend(opts, arg); } } // set auth token from input or environment variable opts.authToken = tokenString ? tokenString : process.env.TWILIO_AUTH_TOKEN; // Create middleware function return function hook(request, response, next) { // Do validation if requested if (opts.validate) { // Check if the 'X-Twilio-Signature' header exists or not if (!request.header('X-Twilio-Signature') && !request.header('X-SignalWire-Signature')) { return response.type('text/plain') .status(400) .send('No signature header error - X-SignalWire-Signature header does not exist, maybe this request is not coming from SignalWire.'); } // Check for a valid auth token if (!opts.authToken) { console.error('[SignalWire]: Error - SignalWire auth token is required for webhook request validation.'); response.type('text/plain') .status(500) .send('Webhook Error - we attempted to validate this request without first configuring our auth token.'); } else { // Check that the request originated from Twilio var valid = validateExpressRequest(request, opts.authToken, { url: opts.url, host: opts.host, protocol: opts.protocol, }); if (valid) { next(); } else { return response .type('text/plain') .status(403) .send('SignalWire Request Validation Failed.'); } } } else { next(); } }; } module.exports = { getExpectedTwilioSignature, getExpectedBodyHash, validateRequest, validateRequestWithBody, validateExpressRequest, validateBody, webhook, };