From 37a227c9dfc78726a733d4e12ecfc6e26432a664 Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Wed, 19 Jun 2019 15:18:24 -0400 Subject: [PATCH 1/7] Add columns for Twilio account credentials. --- ...18151200_messaging_service_account_keys.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 migrations/20190618151200_messaging_service_account_keys.js diff --git a/migrations/20190618151200_messaging_service_account_keys.js b/migrations/20190618151200_messaging_service_account_keys.js new file mode 100644 index 000000000..a4fdb9a05 --- /dev/null +++ b/migrations/20190618151200_messaging_service_account_keys.js @@ -0,0 +1,21 @@ +// Add Twilio account_sid and encrypted_auth_token columns +exports.up = function(knex, Promise) { + return knex.schema.alterTable("messaging_service", table => { + table + .text("account_sid") + .notNullable() + .default(""); + table + .text("encrypted_auth_token") + .notNullable() + .default(""); + }); +}; + +// Remove Twilio account_sid and encrypted_auth_token columns +exports.down = function(knex, Promise) { + return knex.schema.alterTable("messaging_service", table => { + table.dropColumn("account_sid"); + table.dropColumn("encrypted_auth_token"); + }); +}; From 6a096d7a282b6d65bd4bb0acbc16f940225d8cfe Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Wed, 19 Jun 2019 16:41:12 -0400 Subject: [PATCH 2/7] Add crypto utils for symmetric encryption/decryption. --- dev-tools/symmetric-encrypt.js | 10 ++++++++++ src/server/api/lib/crypto.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100755 dev-tools/symmetric-encrypt.js create mode 100644 src/server/api/lib/crypto.js diff --git a/dev-tools/symmetric-encrypt.js b/dev-tools/symmetric-encrypt.js new file mode 100755 index 000000000..d8482262f --- /dev/null +++ b/dev-tools/symmetric-encrypt.js @@ -0,0 +1,10 @@ +// Run this script from the top level directory to encrypt a value: +// +// node ./dev-tools/symmetric-encrypt.js ValueToBeEncrypted + +require("dotenv").config(); +const { symmetricEncrypt } = require("../src/server/api/lib/crypto"); + +const result = symmetricEncrypt(process.argv[2]); +console.log(result); +process.exit(0); diff --git a/src/server/api/lib/crypto.js b/src/server/api/lib/crypto.js new file mode 100644 index 000000000..e6a45fb2b --- /dev/null +++ b/src/server/api/lib/crypto.js @@ -0,0 +1,28 @@ +const crypto = require("crypto"); + +const key = process.env.SESSION_SECRET; +const algorithm = process.env.ENCRYPTION_ALGORITHM || "aes256"; +const inputEncoding = process.env.ENCRYPTION_INPUT_ENCODING || "utf8"; +const outputEncoding = process.env.ENCRYPTION_OUTPUT_ENCODING || "hex"; + +if (!key) { + throw new Error( + "The SESSION_SECRET environment variable must be set to use crypto functions!" + ); +} + +const symmetricEncrypt = value => { + const cipher = crypto.createCipher(algorithm, key); + let encrypted = cipher.update(value, inputEncoding, outputEncoding); + encrypted += cipher.final(outputEncoding); + return encrypted; +}; + +const symmetricDecrypt = encrypted => { + const decipher = crypto.createDecipher(algorithm, key); + let decrypted = decipher.update(encrypted, outputEncoding, inputEncoding); + decrypted += decipher.final(inputEncoding); + return decrypted; +}; + +module.exports = { symmetricEncrypt, symmetricDecrypt }; From 09c7219a8ec2dd84cd7549350675e3f275fdbbb7 Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Wed, 19 Jun 2019 16:43:07 -0400 Subject: [PATCH 3/7] Send message using messaging service-specific Twilio instance. --- src/server/api/lib/twilio.js | 52 ++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/server/api/lib/twilio.js b/src/server/api/lib/twilio.js index 63d5bcb3c..447cd7183 100644 --- a/src/server/api/lib/twilio.js +++ b/src/server/api/lib/twilio.js @@ -7,27 +7,14 @@ import { getCampaignContactAndAssignmentForIncomingMessage, saveNewIncomingMessage } from "./message-sending"; -import faker from "faker"; +import { symmetricDecrypt } from "./crypto"; -let twilio = null; const MAX_SEND_ATTEMPTS = 5; const MESSAGE_VALIDITY_PADDING_SECONDS = 30; const MAX_TWILIO_MESSAGE_VALIDITY = 14400; -if (process.env.TWILIO_API_KEY && process.env.TWILIO_AUTH_TOKEN) { - // eslint-disable-next-line new-cap - twilio = Twilio(process.env.TWILIO_API_KEY, process.env.TWILIO_AUTH_TOKEN); -} else { - log.warn("NO TWILIO CONNECTION"); -} - -if (!process.env.TWILIO_MESSAGE_SERVICE_SID) { - log.warn( - "Twilio will not be able to send without TWILIO_MESSAGE_SERVICE_SID set" - ); -} - function webhook() { + const twilio = undefined; log.warn("twilio webhook call"); // sky: doesn't run this if (twilio) { return Twilio.webhook(); @@ -96,10 +83,8 @@ async function convertMessagePartsToMessage(messageParts) { ); } -async function findNewCell() { - if (!twilio) { - return { availablePhoneNumbers: [{ phone_number: "+15005550006" }] }; - } +async function findNewCell(messagingSericeSid) { + const twilio = await twilioClient(messagingSericeSid); return new Promise((resolve, reject) => { twilio.availablePhoneNumbers("US").local.list({}, (err, data) => { if (err) { @@ -111,10 +96,8 @@ async function findNewCell() { }); } -async function rentNewCell() { - if (!twilio) { - return getFormattedPhoneNumber(faker.phone.phoneNumber()); - } +async function rentNewCell(messagingSericeSid) { + const twilio = await twilioClient(messagingSericeSid); const newCell = await findNewCell(); if ( @@ -127,7 +110,7 @@ async function rentNewCell() { twilio.incomingPhoneNumbers.create( { phoneNumber: newCell.availablePhoneNumbers[0].phone_number, - smsApplicationSid: process.env.TWILIO_APPLICATION_SID + smsApplicationSid: messagingSericeSid }, (err, purchasedNumber) => { if (err) { @@ -212,7 +195,23 @@ const getMessageServiceSID = async (cell, organizationId) => { return await assignMessagingServiceSID(cell, organizationId); }; +const twilioClient = async messagingServiceSid => { + const { account_sid: accountSid, encrypted_auth_token } = await r + .knex("messaging_service") + .first(["account_sid", "encrypted_auth_token"]) + .where({ messaging_service_sid: messagingServiceSid }); + const authToken = symmetricDecrypt(encrypted_auth_token); + return Twilio(accountSid, authToken); +}; + async function sendMessage(message, organizationId, trx) { + // Get (or assign) messaging service for contact's number + const messagingServiceSid = await getMessageServiceSID( + message.contact_number, + organizationId + ); + const twilio = await twilioClient(messagingServiceSid); + if (!twilio) { log.warn( "cannot actually send SMS message -- twilio is not fully configured:", @@ -233,11 +232,6 @@ async function sendMessage(message, organizationId, trx) { log.warn("Message not marked as a twilio message", message.id); } - const messagingServiceSid = await getMessageServiceSID( - message.contact_number, - organizationId - ); - const messageParams = Object.assign( { to: message.contact_number, From 479da8d39ca4c3f6836f6675e5d67427e72c130c Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Wed, 19 Jun 2019 17:12:22 -0400 Subject: [PATCH 4/7] Rewrite incoming message Twilio header validator. --- src/server/api/lib/twilio.js | 38 ++++++++++++++++++++++++------------ src/server/index.js | 9 ++------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/server/api/lib/twilio.js b/src/server/api/lib/twilio.js index 447cd7183..72d95252a 100644 --- a/src/server/api/lib/twilio.js +++ b/src/server/api/lib/twilio.js @@ -13,18 +13,23 @@ const MAX_SEND_ATTEMPTS = 5; const MESSAGE_VALIDITY_PADDING_SECONDS = 30; const MAX_TWILIO_MESSAGE_VALIDITY = 14400; -function webhook() { - const twilio = undefined; - log.warn("twilio webhook call"); // sky: doesn't run this - if (twilio) { - return Twilio.webhook(); - } else { - log.warn("NO TWILIO WEB VALIDATION"); - return function(req, res, next) { - next(); +const incomingMessageWebhook = () => { + const { SKIP_TWILIO_VALIDATION, BASE_URL } = process.env; + if (!!SKIP_TWILIO_VALIDATION) return (req, res, next) => next(); + + return async (req, res, next) => { + const { MessagingServiceSid } = req.body; + const { authToken } = await getTwilioCredentials(MessagingServiceSid); + + const options = { + validate: true, + // host: BASE_URL, + protocol: "https" }; - } -} + + return Twilio.webhook(authToken, options)(req, res, next); + }; +}; const textIncludingMms = (text, serviceMessages) => { const mediaUrls = []; @@ -195,12 +200,19 @@ const getMessageServiceSID = async (cell, organizationId) => { return await assignMessagingServiceSID(cell, organizationId); }; -const twilioClient = async messagingServiceSid => { +const getTwilioCredentials = async messagingServiceSid => { const { account_sid: accountSid, encrypted_auth_token } = await r .knex("messaging_service") .first(["account_sid", "encrypted_auth_token"]) .where({ messaging_service_sid: messagingServiceSid }); const authToken = symmetricDecrypt(encrypted_auth_token); + return { accountSid, authToken }; +}; + +const twilioClient = async messagingServiceSid => { + const { accountSid, authToken } = await getTwilioCredentials( + messagingServiceSid + ); return Twilio(accountSid, authToken); }; @@ -482,7 +494,7 @@ const ensureAllNumbersHaveMessagingServiceSIDs = async ( export default { syncMessagePartProcessing: !!process.env.JOBS_SAME_PROCESS, - webhook, + incomingMessageWebhook, convertMessagePartsToMessage, findNewCell, rentNewCell, diff --git a/src/server/index.js b/src/server/index.js index 2f764a1f8..ca4df3b4d 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -118,14 +118,9 @@ app.post( }) ); -const SKIP_TWILIO_VALIDATION = - process.env.SKIP_TWILIO_VALIDATION === "true" || - process.env.SKIP_TWILIO_VALIDATION === true; - const replyHandlers = []; -if (!SKIP_TWILIO_VALIDATION) { - replyHandlers.push(twilio.webhook()); -} + +replyHandlers.push(twilio.incomingMessageWebhook()); replyHandlers.push( wrap(async (req, res) => { From a6e288d13e601a7900c1f9e0d7b64b0e3bfe344f Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Wed, 19 Jun 2019 17:41:09 -0400 Subject: [PATCH 5/7] Clean up middleware. Add validator to message report endpoint. --- src/server/index.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/server/index.js b/src/server/index.js index ca4df3b4d..7789fd340 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -118,11 +118,9 @@ app.post( }) ); -const replyHandlers = []; - -replyHandlers.push(twilio.incomingMessageWebhook()); - -replyHandlers.push( +app.post( + "/twilio", + twilio.incomingMessageWebhook(), wrap(async (req, res) => { try { await twilio.handleIncomingMessage(req.body); @@ -136,8 +134,6 @@ replyHandlers.push( }) ); -app.post("/twilio", ...replyHandlers); - app.post( "/nexmo-message-report", wrap(async (req, res) => { @@ -153,6 +149,7 @@ app.post( app.post( "/twilio-message-report", + twilio.incomingMessageWebhook(), wrap(async (req, res) => { try { const body = req.body; From c07b4ae2380f961241cc1589cea412525fab0e40 Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Wed, 19 Jun 2019 17:47:22 -0400 Subject: [PATCH 6/7] Add option for overriding the host used for Twilio header validation. --- docs/REFERENCE-environment_variables.md | 2 ++ src/server/api/lib/twilio.js | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index 0e3cf9de6..6e9ba88fd 100644 --- a/docs/REFERENCE-environment_variables.md +++ b/docs/REFERENCE-environment_variables.md @@ -72,6 +72,8 @@ | TWILIO_MESSAGE_SERVICE_SID | Twilio message service ID. Required if using Twilio. | | TWILIO_STATUS_CALLBACK_URL | URL for Twilio status callbacks. Should end with `/twilio-message-report`, e.g. `https://example.org/twilio-message-report`. Required if using Twilio. | | TWILIO_SQS_QUEUE_URL | AWS SQS URL to handle incoming messages when app isn't connected to twilio | +| TWILIO_VALIDATION_HOST | Allow overriding the host Spoke validates Twilio headers against. This can be useful if you are running Spoke behind a proxy. If set to `""`, the `host` option will be `undefined`. | +| SKIP_TWILIO_VALIDATION | Whether to bypass Twilio header validation altogether. | | WAREHOUSE*DB*{TYPE,HOST,PORT,NAME,USER,PASSWORD} | Enables ability to load contacts directly from a SQL query from a separate data-warehouse db -- only is_superadmin-marked users will see the interface | | WAREHOUSE_DB_LAMBDA_ITERATION | If the WAREHOUSE*DB* connection/feature is enabled, then on AWS Lambda, queries that take longer than 5min can expire. This will enable incrementing through queries on new lambda invocations to avoid timeouts. | | WEBPACK_HOST | Host domain or IP for Webpack development server. _Default_: 127.0.0.1. | diff --git a/src/server/api/lib/twilio.js b/src/server/api/lib/twilio.js index 72d95252a..92667f9dd 100644 --- a/src/server/api/lib/twilio.js +++ b/src/server/api/lib/twilio.js @@ -14,17 +14,28 @@ const MESSAGE_VALIDITY_PADDING_SECONDS = 30; const MAX_TWILIO_MESSAGE_VALIDITY = 14400; const incomingMessageWebhook = () => { - const { SKIP_TWILIO_VALIDATION, BASE_URL } = process.env; + const { + SKIP_TWILIO_VALIDATION, + TWILIO_VALIDATION_HOST, + BASE_URL + } = process.env; if (!!SKIP_TWILIO_VALIDATION) return (req, res, next) => next(); return async (req, res, next) => { const { MessagingServiceSid } = req.body; const { authToken } = await getTwilioCredentials(MessagingServiceSid); + // Allow setting + const host = TWILIO_VALIDATION_HOST + ? TWILIO_VALIDATION_HOST !== "" + ? TWILIO_VALIDATION_HOST + : undefined + : BASE_URL; + const options = { validate: true, - // host: BASE_URL, - protocol: "https" + protocol: "https", + host }; return Twilio.webhook(authToken, options)(req, res, next); From 776b2dd3f2b234e00aea8f4a3adf71888459b5f2 Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Wed, 19 Jun 2019 17:49:39 -0400 Subject: [PATCH 7/7] Rename header validator middleware. --- src/server/api/lib/twilio.js | 4 ++-- src/server/index.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/api/lib/twilio.js b/src/server/api/lib/twilio.js index 92667f9dd..49008e599 100644 --- a/src/server/api/lib/twilio.js +++ b/src/server/api/lib/twilio.js @@ -13,7 +13,7 @@ const MAX_SEND_ATTEMPTS = 5; const MESSAGE_VALIDITY_PADDING_SECONDS = 30; const MAX_TWILIO_MESSAGE_VALIDITY = 14400; -const incomingMessageWebhook = () => { +const headerValidator = () => { const { SKIP_TWILIO_VALIDATION, TWILIO_VALIDATION_HOST, @@ -505,7 +505,7 @@ const ensureAllNumbersHaveMessagingServiceSIDs = async ( export default { syncMessagePartProcessing: !!process.env.JOBS_SAME_PROCESS, - incomingMessageWebhook, + headerValidator, convertMessagePartsToMessage, findNewCell, rentNewCell, diff --git a/src/server/index.js b/src/server/index.js index 7789fd340..aee3d0fa6 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -120,7 +120,7 @@ app.post( app.post( "/twilio", - twilio.incomingMessageWebhook(), + twilio.headerValidator(), wrap(async (req, res) => { try { await twilio.handleIncomingMessage(req.body); @@ -149,7 +149,7 @@ app.post( app.post( "/twilio-message-report", - twilio.incomingMessageWebhook(), + twilio.headerValidator(), wrap(async (req, res) => { try { const body = req.body;