Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Messaging Service-specific Twilio client and header validation #193

Merged
10 changes: 10 additions & 0 deletions dev-tools/symmetric-encrypt.js
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions docs/REFERENCE-environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
21 changes: 21 additions & 0 deletions migrations/20190618151200_messaging_service_account_keys.js
Original file line number Diff line number Diff line change
@@ -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");
});
};
28 changes: 28 additions & 0 deletions src/server/api/lib/crypto.js
Original file line number Diff line number Diff line change
@@ -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 };
97 changes: 57 additions & 40 deletions src/server/api/lib/twilio.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,40 @@ 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() {
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 headerValidator = () => {
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,
protocol: "https",
host
};
}
}

return Twilio.webhook(authToken, options)(req, res, next);
};
};

const textIncludingMms = (text, serviceMessages) => {
const mediaUrls = [];
Expand Down Expand Up @@ -96,10 +99,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) {
Expand All @@ -111,10 +112,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 (
Expand All @@ -127,7 +126,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) {
Expand Down Expand Up @@ -212,7 +211,30 @@ const getMessageServiceSID = async (cell, organizationId) => {
return await assignMessagingServiceSID(cell, organizationId);
};

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);
};

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:",
Expand All @@ -233,11 +255,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,
Expand Down Expand Up @@ -488,7 +505,7 @@ const ensureAllNumbersHaveMessagingServiceSIDs = async (

export default {
syncMessagePartProcessing: !!process.env.JOBS_SAME_PROCESS,
webhook,
headerValidator,
convertMessagePartsToMessage,
findNewCell,
rentNewCell,
Expand Down
16 changes: 4 additions & 12 deletions src/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +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(
app.post(
"/twilio",
twilio.headerValidator(),
wrap(async (req, res) => {
try {
await twilio.handleIncomingMessage(req.body);
Expand All @@ -141,8 +134,6 @@ replyHandlers.push(
})
);

app.post("/twilio", ...replyHandlers);

app.post(
"/nexmo-message-report",
wrap(async (req, res) => {
Expand All @@ -158,6 +149,7 @@ app.post(

app.post(
"/twilio-message-report",
twilio.headerValidator(),
wrap(async (req, res) => {
try {
const body = req.body;
Expand Down