diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index b579f490a4d88..d8546be970f4c 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -108,7 +108,7 @@ API.v1.addRoute('livechat/sms-incoming/:service', { const smsDepartment = settings.get('SMS_Default_Omnichannel_Department'); const SMSService = await OmnichannelIntegration.getSmsService(service); - if (!(await SMSService.validateRequest(this.request.clone()))) { + if (!(await SMSService.validateRequest(this.request.clone(), this.bodyParams))) { return API.v1.failure('Invalid request'); } diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts b/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts index c1e5e32018926..dd8d18b40555b 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts @@ -196,7 +196,7 @@ export class Mobex implements ISMSProvider { }; } - async validateRequest(_request: Request): Promise { + async validateRequest(_request: Request, _requestBody: unknown): Promise { return true; } diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts index 7d4c4a48e1a20..26b6dbaea1cf9 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts @@ -244,7 +244,16 @@ export class Twilio implements ISMSProvider { }; } - async isRequestFromTwilio(signature: string, request: Request): Promise { + private getUrl(url: string, siteUrl: string): string { + const baseUrl = new URL(url); + const newUrl = new URL(siteUrl); + baseUrl.protocol = newUrl.protocol; + baseUrl.host = newUrl.host; + + return baseUrl.toString(); + } + + async isRequestFromTwilio(signature: string, request: Request, requestBody: unknown): Promise { const authToken = settings.get('SMS_Twilio_authToken'); let siteUrl = settings.get('Site_Url'); if (siteUrl.endsWith('/')) { @@ -256,25 +265,19 @@ export class Twilio implements ISMSProvider { return false; } - const twilioUrl = request.url ? `${siteUrl}${request.url}` : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`; - - let body = {}; - try { - body = await request.json(); - // eslint-disable-next-line no-empty - } catch {} + const twilioUrl = request.url ? this.getUrl(request.url, siteUrl) : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`; - return twilio.validateRequest(authToken, signature, twilioUrl, body); + return twilio.validateRequest(authToken, signature, twilioUrl, requestBody as Record); } - async validateRequest(request: Request): Promise { + async validateRequest(request: Request, requestBody: unknown): Promise { // We're not getting original twilio requests on CI :p if (process.env.TEST_MODE === 'true') { return true; } const twilioHeader = request.headers.get('x-twilio-signature') || ''; const twilioSignature = Array.isArray(twilioHeader) ? twilioHeader[0] : twilioHeader; - return this.isRequestFromTwilio(twilioSignature, request); + return this.isRequestFromTwilio(twilioSignature, request, requestBody); } error(error: Error & { reason?: string }): SMSProviderResponse { diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts b/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts index 063070a30e7e8..b5e8268bbdbcb 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts @@ -162,7 +162,7 @@ export class Voxtelesys implements ISMSProvider { }; } - async validateRequest(_request: Request): Promise { + async validateRequest(_request: Request, _requestBody: unknown): Promise { return true; } diff --git a/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts b/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts index 9ee9471d8b41b..6e72b9c30fa08 100644 --- a/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts +++ b/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts @@ -89,10 +89,9 @@ describe('Twilio Request Validation', () => { return headers[param]; }, }, - json: () => requestBody, }; - expect(await twilio.validateRequest(request)).to.be.true; + expect(await twilio.validateRequest(request, requestBody)).to.be.true; }); it('should validate a request when query string is present', async () => { @@ -109,7 +108,7 @@ describe('Twilio Request Validation', () => { }; const request = { - url: '/api/v1/livechat/sms-incoming/twilio?department=1', + url: 'https://example.com/api/v1/livechat/sms-incoming/twilio?department=1', headers: { get: (param: string) => { const headers: Record = { @@ -119,10 +118,9 @@ describe('Twilio Request Validation', () => { return headers[param]; }, }, - json: () => requestBody, }; - expect(await twilio.validateRequest(request)).to.be.true; + expect(await twilio.validateRequest(request, requestBody)).to.be.true; }); it('should reject a request where signature doesnt match', async () => { @@ -146,10 +144,9 @@ describe('Twilio Request Validation', () => { return headers[param]; }, }, - json: () => requestBody, }; - expect(await twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request, requestBody)).to.be.false; }); it('should reject a request where signature is missing', async () => { @@ -167,10 +164,9 @@ describe('Twilio Request Validation', () => { headers: { get: () => null, }, - json: () => requestBody, }; - expect(await twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request, requestBody)).to.be.false; }); it('should reject a request where the signature doesnt correspond body', async () => { @@ -194,10 +190,9 @@ describe('Twilio Request Validation', () => { return headers[param]; }, }, - json: () => requestBody, }; - expect(await twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request, requestBody)).to.be.false; }); it('should return false if URL is not provided', async () => { @@ -223,10 +218,9 @@ describe('Twilio Request Validation', () => { return headers[param]; }, }, - json: () => requestBody, }; - expect(await twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request, requestBody)).to.be.false; }); it('should return false if authToken is not provided', async () => { @@ -252,9 +246,38 @@ describe('Twilio Request Validation', () => { return headers[param]; }, }, - json: () => requestBody, }; - expect(await twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request, requestBody)).to.be.false; + }); + + // Twilio signature will always use the workspace public URL, which may difer from the request URL in some circumstances. + it('should use siteURL instead of request.url hostname', async () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://ngrok.barn.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + url: 'https://example.com/api/v1/livechat/sms-incoming/twilio', + headers: { + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://ngrok.barn.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.true; }); }); diff --git a/packages/core-typings/src/omnichannel/sms.ts b/packages/core-typings/src/omnichannel/sms.ts index c29437910066d..72afb2c8ded71 100644 --- a/packages/core-typings/src/omnichannel/sms.ts +++ b/packages/core-typings/src/omnichannel/sms.ts @@ -27,7 +27,7 @@ export interface ISMSProviderConstructor { export interface ISMSProvider { parse(data: unknown): ServiceData; - validateRequest(request: Request): Promise; + validateRequest(request: Request, requestBody: unknown): Promise; sendBatch?(from: string, to: string[], message: string): Promise; response(): SMSProviderResponse;