diff --git a/.env.defaults b/.env.defaults index d12f5b377b..81139e9390 100644 --- a/.env.defaults +++ b/.env.defaults @@ -323,6 +323,14 @@ HELPER_ENCRYPTION_KEY_LEGACY= # generate with `crypto.randomBytes(16).toString('hex')` TXT_ENCRYPTION_KEY= +########################### +## Webhook Signature Key ## +########################### +# SHA256 HMAC should not exceed 512 bytes for key length +# +# randomBytes(16).toString('hex') +WEBHOOK_SIGNATURE_KEY= + ########################### ## api restricted symbol ## ########################### diff --git a/.env.schema b/.env.schema index defa6a5007..2fdd5b7ef9 100644 --- a/.env.schema +++ b/.env.schema @@ -313,6 +313,14 @@ HELPER_ENCRYPTION_KEY_LEGACY= # generate with `crypto.randomBytes(16).toString('hex')` TXT_ENCRYPTION_KEY= +########################### +## Webhook Signature Key ## +########################### +# SHA256 HMAC should not exceed 512 bytes for key length +# +# randomBytes(16).toString('hex') +WEBHOOK_SIGNATURE_KEY= + ########################### ## api restricted symbol ## ########################### diff --git a/app/controllers/api/v1/inquiries.js b/app/controllers/api/v1/inquiries.js index 93c62400f2..ab112d9f20 100644 --- a/app/controllers/api/v1/inquiries.js +++ b/app/controllers/api/v1/inquiries.js @@ -3,15 +3,20 @@ * SPDX-License-Identifier: BUSL-1.1 */ +const { createHmac } = require('node:crypto'); +const process = require('node:process'); const Boom = require('@hapi/boom'); const isSANB = require('is-string-and-not-blank'); const { isEmail } = require('validator'); -const { simpleParser } = require('mailparser'); +const { Headers } = require('mailsplit'); +const { decrypt } = require('./encrypt-decrypt'); const config = require('#config'); const env = require('#config/env'); const { Inquiries, Users } = require('#models'); +const webhookSignatureKey = process.env.WEBHOOK_SIGNATURE_KEY; + function findHeaderByName(name, headers) { for (const header of headers) { const key = header.key.toLowerCase(); @@ -25,12 +30,28 @@ function findHeaderByName(name, headers) { // eslint-disable-next-line complexity async function create(ctx) { - const { body } = ctx.request; + const { body, headers: requestHeaders } = ctx.request; ctx.logger.info('creating inquiry from webhook'); - // TODO: Add support for webhook payload signature: - // https://stackoverflow.com/questions/68885086/how-to-create-signed-webhook-requests-in-nodejs/68885281#68885281 + if (!requestHeaders['X-Webhook-Signature']) { + return ctx.throw( + Boom.badRequest( + ctx.translateError('MISSING_INQUIRY_WEBHOOK_SIGNATURE_HEADER') + ) + ); + } + + const webhookSignature = createHmac('sha256', decrypt(webhookSignatureKey)) + .update(body) + .digest('hex'); + + if (requestHeaders['X-Webhook-Signature'] !== webhookSignature) { + return ctx.throw( + Boom.forbidden(ctx.translateError('INVALID_INQUIRY_WEBHOOK_SIGNATURE')) + ); + } + if ( !ctx.allowlistValue || ![env.MX1_HOST, env.MX2_HOST, env.WEB_HOST].includes(ctx.allowlistValue) @@ -39,15 +60,10 @@ async function create(ctx) { Boom.forbidden(ctx.translateError('INVALID_INQUIRY_WEBHOOK_REQUEST')) ); - let parsed; - try { - parsed = await simpleParser(body.raw); - } catch (err) { - ctx.logger.error(err); - return ctx.throw(err); - } - const { headerLines, session, text } = body; + + const headers = new Headers(headerLines); + if (!session) return ctx.throw( Boom.badRequest(ctx.translateError('INVALID_INQUIRY_WEBHOOK_PAYLOAD')) @@ -59,29 +75,29 @@ async function create(ctx) { ); if ( - (parsed.headers.has('Auto-submitted') && - parsed.headers.get('Auto-submitted') !== 'no') || - (parsed.headers.has('Auto-Submitted') && - parsed.headers.get('Auto-Submitted') !== 'no') || - (parsed.headers.has('X-Auto-Response-Suppress') && + (headers.hasHeader('Auto-submitted') && + headers.getFirst('Auto-submitted') !== 'no') || + (headers.hasHeader('Auto-Submitted') && + headers.getFirst('Auto-Submitted') !== 'no') || + (headers.hasHeader('X-Auto-Response-Suppress') && ['dr', 'autoreply', 'auto-reply', 'auto_reply', 'all'].includes( - parsed.headers.get('X-Auto-Response-Suppress').toLowerCase().trim() + headers.getFirst('X-Auto-Response-Suppress').toLowerCase().trim() )) || - parsed.headers.has('List-Id') || - parsed.headers.has('List-id') || - parsed.headers.has('List-Unsubscribe') || - parsed.headers.has('List-unsubscribe') || - parsed.headers.has('Feedback-ID') || - parsed.headers.has('Feedback-Id') || - parsed.headers.has('X-Autoreply') || - parsed.headers.has('X-Auto-Reply') || - parsed.headers.has('X-AutoReply') || - parsed.headers.has('X-Autorespond') || - parsed.headers.has('X-Auto-Respond') || - parsed.headers.has('X-AutoRespond') || - (parsed.headers.has('Precedence') && + headers.hasHeader('List-Id') || + headers.hasHeader('List-id') || + headers.hasHeader('List-Unsubscribe') || + headers.hasHeader('List-unsubscribe') || + headers.hasHeader('Feedback-ID') || + headers.hasHeader('Feedback-Id') || + headers.hasHeader('X-Autoreply') || + headers.hasHeader('X-Auto-Reply') || + headers.hasHeader('X-AutoReply') || + headers.hasHeader('X-Autorespond') || + headers.hasHeader('X-Auto-Respond') || + headers.hasHeader('X-AutoRespond') || + (headers.hasHeader('Precedence') && ['bulk', 'autoreply', 'auto-reply', 'auto_reply', 'list'].includes( - parsed.headers.get('Precedence').toLowerCase().trim() + headers.getFirst('Precedence').toLowerCase().trim() )) ) return; diff --git a/config/phrases.js b/config/phrases.js index ab6359fe16..d0fa1b767d 100644 --- a/config/phrases.js +++ b/config/phrases.js @@ -273,6 +273,9 @@ module.exports = { INVALID_INQUIRY_WEBHOOK_PAYLOAD: 'Invalid inquiry webhook payload.', INVALID_INQUIRY_WEBHOOK_REQUEST: 'Webhook request did not originate from a valid hostname', + MISSING_INQUIRY_WEBHOOK_SIGNATURE_HEADER: + 'Webhook request missing X-Signature-Header', + INVALID_INQUIRY_WEBHOOK_SIGNATURE: 'Invalid signature in webhook request', INVALID_USER: 'User does not exist.', INVALID_LOG: 'Log does not exist.', INVALID_MEMBER: 'Member does not exist.',