From e1379aa2545598dff525589bcccfd5d6ba7f1c17 Mon Sep 17 00:00:00 2001 From: "Julio A." <52619625+julio-cfa@users.noreply.github.com> Date: Thu, 15 Aug 2024 23:02:15 +0200 Subject: [PATCH] fix: security hotfix (#33062) --- .changeset/perfect-dancers-grab.md | 5 + apps/meteor/app/api/server/v1/rooms.ts | 10 +- apps/meteor/app/api/server/v1/users.ts | 6 + .../app/livechat/imports/server/rest/sms.ts | 12 +- .../views/admin/oauthApps/EditOauthApp.tsx | 3 +- apps/meteor/ee/server/lib/audit/methods.ts | 46 +++- apps/meteor/server/lib/ldap/Manager.ts | 2 +- apps/meteor/server/methods/reportMessage.ts | 13 ++ .../server/methods/sendConfirmationEmail.ts | 13 ++ .../providers/mobex.ts | 5 + .../providers/twilio.ts | 26 +++ .../providers/voxtelesys.ts | 5 + apps/meteor/server/settings/message.ts | 2 +- apps/meteor/tests/end-to-end/api/01-users.js | 41 +++- apps/meteor/tests/end-to-end/api/09-rooms.js | 1 - .../meteor/tests/end-to-end/api/24-methods.js | 162 +++++++++++++- .../providers/twilio.spec.ts | 211 ++++++++++++++++++ packages/core-typings/package.json | 3 +- packages/core-typings/src/omnichannel/sms.ts | 3 + packages/rest-typings/src/v1/rooms.ts | 69 +++++- yarn.lock | 13 ++ 21 files changed, 605 insertions(+), 46 deletions(-) create mode 100644 .changeset/perfect-dancers-grab.md create mode 100644 apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts diff --git a/.changeset/perfect-dancers-grab.md b/.changeset/perfect-dancers-grab.md new file mode 100644 index 000000000000..eacb88108a0f --- /dev/null +++ b/.changeset/perfect-dancers-grab.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index e3296b98ef17..ee76dcdd9fe3 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -2,7 +2,13 @@ import { Media } from '@rocket.chat/core-services'; import type { IRoom, IUpload } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, Uploads } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; -import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, isRoomsExportProps } from '@rocket.chat/rest-typings'; +import { + isGETRoomsNameExists, + isRoomsCleanHistoryProps, + isRoomsImagesProps, + isRoomsMuteUnmuteUserProps, + isRoomsExportProps, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { isTruthy } from '../../../../lib/isTruthy'; @@ -355,7 +361,7 @@ API.v1.addRoute( API.v1.addRoute( 'rooms.cleanHistory', - { authRequired: true }, + { authRequired: true, validateParams: isRoomsCleanHistoryProps }, { async post() { const { _id } = await findRoomByIdOrName({ params: this.bodyParams }); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 410a65fe7eda..b683e3a1fd08 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -749,6 +749,12 @@ API.v1.addRoute( { authRequired: false }, { async post() { + const isPasswordResetEnabled = settings.get('Accounts_PasswordReset'); + + if (!isPasswordResetEnabled) { + return API.v1.failure('Password reset is not enabled'); + } + const { email } = this.bodyParams; if (!email) { return API.v1.failure("The 'email' param is required"); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index f6502b70f68a..2a5a0643477c 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -92,12 +92,18 @@ const normalizeLocationSharing = (payload: ServiceData) => { // @ts-expect-error - this is an special endpoint that requires the return to not be wrapped as regular returns API.v1.addRoute('livechat/sms-incoming/:service', { async post() { - if (!(await OmnichannelIntegration.isConfiguredSmsService(this.urlParams.service))) { + const { service } = this.urlParams; + if (!(await OmnichannelIntegration.isConfiguredSmsService(service))) { return API.v1.failure('Invalid service'); } const smsDepartment = settings.get('SMS_Default_Omnichannel_Department'); - const SMSService = await OmnichannelIntegration.getSmsService(this.urlParams.service); + const SMSService = await OmnichannelIntegration.getSmsService(service); + + if (!SMSService.validateRequest(this.request)) { + return API.v1.failure('Invalid request'); + } + const sms = SMSService.parse(this.bodyParams); const { department } = this.queryParams; let targetDepartment = await defineDepartment(department || smsDepartment); @@ -122,7 +128,7 @@ API.v1.addRoute('livechat/sms-incoming/:service', { }, source: { type: OmnichannelSourceType.SMS, - alias: this.urlParams.service, + alias: service, }, }; diff --git a/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx b/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx index 19bef201492f..79d2fbe11140 100644 --- a/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx +++ b/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx @@ -8,6 +8,7 @@ import { FieldRow, FieldError, FieldHint, + PasswordInput, TextAreaInput, ToggleSwitch, FieldGroup, @@ -136,7 +137,7 @@ const EditOauthApp = ({ onChange, data, ...props }: EditOauthAppProps): ReactEle {t('Client_Secret')} - + diff --git a/apps/meteor/ee/server/lib/audit/methods.ts b/apps/meteor/ee/server/lib/audit/methods.ts index add64414c6e8..b221f4a2f1e1 100644 --- a/apps/meteor/ee/server/lib/audit/methods.ts +++ b/apps/meteor/ee/server/lib/audit/methods.ts @@ -88,11 +88,18 @@ Meteor.methods({ check(startDate, Date); check(endDate, Date); - const user = await Meteor.userAsync(); + const user = (await Meteor.userAsync()) as IUser; if (!user || !(await hasPermissionAsync(user._id, 'can-audit'))) { throw new Meteor.Error('Not allowed'); } + const userFields = { + _id: user._id, + username: user.username, + ...(user.name && { name: user.name }), + ...(user.avatarETag && { avatarETag: user.avatarETag }), + }; + const rooms: IRoom[] = await LivechatRooms.findByVisitorIdAndAgentId(visitor, agent, { projection: { _id: 1 }, }).toArray(); @@ -118,7 +125,7 @@ Meteor.methods({ await AuditLog.insertOne({ ts: new Date(), results: messages.length, - u: user, + u: userFields, fields: { msg, users: usernames, rids, room: name, startDate, endDate, type, visitor, agent }, }); @@ -128,11 +135,18 @@ Meteor.methods({ check(startDate, Date); check(endDate, Date); - const user = await Meteor.userAsync(); + const user = (await Meteor.userAsync()) as IUser; if (!user || !(await hasPermissionAsync(user._id, 'can-audit'))) { throw new Meteor.Error('Not allowed'); } + const userFields = { + _id: user._id, + username: user.username, + ...(user.name && { name: user.name }), + ...(user.avatarETag && { avatarETag: user.avatarETag }), + }; + let rids; let name; @@ -169,9 +183,10 @@ Meteor.methods({ await AuditLog.insertOne({ ts: new Date(), results: messages.length, - u: user, + u: userFields, fields: { msg, users: usernames, rids, room: name, startDate, endDate, type, visitor, agent }, }); + updateCounter({ settingsId: 'Message_Auditing_Panel_Load_Count' }); return messages; @@ -183,13 +198,24 @@ Meteor.methods({ if (!uid || !(await hasPermissionAsync(uid, 'can-audit-log'))) { throw new Meteor.Error('Not allowed'); } - return AuditLog.find({ - // 'u._id': userId, - ts: { - $gt: startDate, - $lt: endDate, + return AuditLog.find( + { + // 'u._id': userId, + ts: { + $gt: startDate, + $lt: endDate, + }, }, - }).toArray(); + { + projection: { + 'u.services': 0, + 'u.roles': 0, + 'u.lastLogin': 0, + 'u.statusConnection': 0, + 'u.emails': 0, + }, + }, + ).toArray(); }, }); diff --git a/apps/meteor/server/lib/ldap/Manager.ts b/apps/meteor/server/lib/ldap/Manager.ts index 4a5cdf2df8d6..7dd4fba9bf9b 100644 --- a/apps/meteor/server/lib/ldap/Manager.ts +++ b/apps/meteor/server/lib/ldap/Manager.ts @@ -163,7 +163,7 @@ export class LDAPManager { const { attribute: idAttribute, value: id } = uniqueId; const username = this.slugifyUsername(ldapUser, usedUsername || id || '') || undefined; - const emails = this.getLdapEmails(ldapUser, username); + const emails = this.getLdapEmails(ldapUser, username).map((email) => email.trim()); const name = this.getLdapName(ldapUser) || undefined; const userData: IImportUser = { diff --git a/apps/meteor/server/methods/reportMessage.ts b/apps/meteor/server/methods/reportMessage.ts index 05ac5aaf7e7b..14ef69189b33 100644 --- a/apps/meteor/server/methods/reportMessage.ts +++ b/apps/meteor/server/methods/reportMessage.ts @@ -3,6 +3,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { ModerationReports, Rooms, Users, Messages } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../app/authorization/server/functions/canAccessRoom'; @@ -82,3 +83,15 @@ Meteor.methods({ return true; }, }); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'reportMessage', + userId() { + return true; + }, + }, + 5, + 60000, +); diff --git a/apps/meteor/server/methods/sendConfirmationEmail.ts b/apps/meteor/server/methods/sendConfirmationEmail.ts index 8c7d056532d3..9ed7e0b99da2 100644 --- a/apps/meteor/server/methods/sendConfirmationEmail.ts +++ b/apps/meteor/server/methods/sendConfirmationEmail.ts @@ -2,6 +2,7 @@ import { Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Accounts } from 'meteor/accounts-base'; import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; @@ -31,3 +32,15 @@ Meteor.methods({ } }, }); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'sendConfirmationEmail', + userId() { + return true; + }, + }, + 5, + 60000, +); diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts b/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts index 19284b8e1e6b..d036345663cd 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts @@ -1,6 +1,7 @@ import { Base64 } from '@rocket.chat/base64'; import type { ISMSProvider, ServiceData, SMSProviderResult, SMSProviderResponse } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import type { Request } from 'express'; import { settings } from '../../../../app/settings/server'; import { SystemLogger } from '../../../lib/logger/system'; @@ -196,6 +197,10 @@ export class Mobex implements ISMSProvider { }; } + validateRequest(_request: Request): boolean { + return true; + } + error(error: Error & { reason?: string }): SMSProviderResponse { let message = ''; if (error.reason) { diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts index 3aff869a95a0..4a1c8d9d0ebf 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts @@ -1,6 +1,7 @@ import { api } from '@rocket.chat/core-services'; import type { ISMSProvider, ServiceData, SMSProviderResponse, SMSProviderResult } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; +import type { Request } from 'express'; import filesize from 'filesize'; import twilio from 'twilio'; @@ -244,6 +245,31 @@ export class Twilio implements ISMSProvider { }; } + isRequestFromTwilio(signature: string, requestBody: object): boolean { + const authToken = settings.get('SMS_Twilio_authToken'); + const siteUrl = settings.get('Site_Url'); + + if (!authToken || !siteUrl) { + SystemLogger.error(`(Twilio) -> URL or Twilio token not configured.`); + return false; + } + + const twilioUrl = siteUrl.endsWith('/') + ? `${siteUrl}api/v1/livechat/sms-incoming/twilio` + : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`; + return twilio.validateRequest(authToken, signature, twilioUrl, requestBody); + } + + validateRequest(request: Request): boolean { + // We're not getting original twilio requests on CI :p + if (process.env.TEST_MODE === 'true') { + return true; + } + const twilioHeader = request.headers['x-twilio-signature'] || ''; + const twilioSignature = Array.isArray(twilioHeader) ? twilioHeader[0] : twilioHeader; + return this.isRequestFromTwilio(twilioSignature, request.body); + } + error(error: Error & { reason?: string }): SMSProviderResponse { let message = ''; if (error.reason) { diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts b/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts index 3e78907bbf75..aa42bacad624 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts @@ -2,6 +2,7 @@ import { api } from '@rocket.chat/core-services'; import type { ISMSProvider, ServiceData, SMSProviderResponse } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import type { Request } from 'express'; import filesize from 'filesize'; import { settings } from '../../../../app/settings/server'; @@ -162,6 +163,10 @@ export class Voxtelesys implements ISMSProvider { }; } + validateRequest(_request: Request): boolean { + return true; + } + error(error: Error & { reason?: string }): SMSProviderResponse { let message = ''; if (error.reason) { diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index 6ec6e3355655..520af87d2333 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -32,7 +32,7 @@ export const createMessageSettings = () => ], }); - await this.add('Message_Attachments_Strip_Exif', false, { + await this.add('Message_Attachments_Strip_Exif', true, { type: 'boolean', public: true, i18nDescription: 'Message_Attachments_Strip_ExifDescription', diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index 535e989dce95..a9f59a44d268 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -2470,18 +2470,37 @@ describe('[Users]', function () { }); describe('[/users.forgotPassword]', () => { + it('should return an error when "Accounts_PasswordReset" is disabled', (done) => { + void updateSetting('Accounts_PasswordReset', false).then(() => { + void request + .post(api('users.forgotPassword')) + .send({ + email: adminEmail, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Password reset is not enabled'); + }) + .end(done); + }); + }); + it('should send email to user (return success), when is a valid email', (done) => { - request - .post(api('users.forgotPassword')) - .send({ - email: adminEmail, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + void updateSetting('Accounts_PasswordReset', true).then(() => { + void request + .post(api('users.forgotPassword')) + .send({ + email: adminEmail, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); it('should not send email to user(return error), when is a invalid email', (done) => { diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index de4668e86f48..829ec347c3c2 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -1027,7 +1027,6 @@ describe('[Rooms]', function () { .end(done); }); }); - describe('[/rooms.info]', () => { let testChannel; let testGroup; diff --git a/apps/meteor/tests/end-to-end/api/24-methods.js b/apps/meteor/tests/end-to-end/api/24-methods.js index aabd24e04119..766e40ada222 100644 --- a/apps/meteor/tests/end-to-end/api/24-methods.js +++ b/apps/meteor/tests/end-to-end/api/24-methods.js @@ -2029,6 +2029,13 @@ describe('Meteor.methods', function () { let messageWithMarkdownId; let channelName = false; const siteUrl = process.env.SITE_URL || process.env.TEST_API_URL || 'http://localhost:3000'; + let testUser; + let testUserCredentials; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); before('create room', (done) => { channelName = `methods-test-channel-${Date.now()}`; @@ -2124,13 +2131,14 @@ describe('Meteor.methods', function () { after(() => Promise.all([ deleteRoom({ type: 'p', roomId: rid }), + deleteUser(testUser), updatePermission('bypass-time-limit-edit-and-delete', ['bot', 'app']), updateSetting('Message_AllowEditing_BlockEditInMinutes', 0), ]), ); - it('should update a message with a URL', (done) => { - request + it('should update a message with a URL', async () => { + await request .post(methodCall('updateMessage')) .set(credentials) .send({ @@ -2148,8 +2156,53 @@ describe('Meteor.methods', function () { expect(res.body).to.have.a.property('message').that.is.a('string'); const data = JSON.parse(res.body.message); expect(data).to.have.a.property('msg').that.is.an('string'); + }); + }); + + it('should fail if user does not have permissions to update a message with the same content', async () => { + await request + .post(methodCall('updateMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'test message with https://github.com' }], + id: 'id', + msg: 'method', + }), }) - .end(done); + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg').that.is.an('string'); + expect(data.error).to.have.a.property('error', 'error-action-not-allowed'); + }); + }); + + it('should fail if user does not have permissions to update a message with different content', async () => { + await request + .post(methodCall('updateMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'updating test message with https://github.com' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg').that.is.an('string'); + expect(data.error).to.have.a.property('error', 'error-action-not-allowed'); + }); }); it('should add a quote attachment to a message', async () => { @@ -3288,4 +3341,107 @@ describe('Meteor.methods', function () { .end(done); }); }); + (IS_EE ? describe : describe.skip)('[@auditGetAuditions] EE', () => { + let testUser; + let testUserCredentials; + + const now = new Date(); + const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0).getTime(); + const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime(); + + before('create test user', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + before('generate audits data', async () => { + await request + .post(methodCall('auditGetMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'auditGetMessages', + params: [ + { + type: '', + msg: 'test1234', + startDate: { $date: startDate }, + endDate: { $date: endDate }, + rid: 'GENERAL', + users: [], + }, + ], + id: '14', + msg: 'method', + }), + }); + }); + + after(() => Promise.all([deleteUser(testUser)])); + + it('should fail if the user does not have permissions to get auditions', async () => { + await request + .post(methodCall('auditGetAuditions')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'auditGetAuditions', + params: [ + { + startDate: { $date: startDate }, + endDate: { $date: endDate }, + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error'); + expect(data.error).to.have.a.property('error', 'Not allowed'); + }); + }); + + it('should not return more user data than necessary - e.g. passwords, hashes, tokens', async () => { + await request + .post(methodCall('auditGetAuditions')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'auditGetAuditions', + params: [ + { + startDate: { $date: startDate }, + endDate: { $date: endDate }, + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.be.greaterThan(0); + expect(data).to.have.a.property('msg', 'result'); + expect(data).to.have.a.property('id', '18'); + data.result.forEach((item) => { + expect(item).to.have.all.keys('_id', 'ts', 'results', 'u', 'fields', '_updatedAt'); + expect(item.u).to.not.have.property('services'); + expect(item.u).to.not.have.property('roles'); + expect(item.u).to.not.have.property('lastLogin'); + expect(item.u).to.not.have.property('statusConnection'); + expect(item.u).to.not.have.property('emails'); + }); + }); + }); + }); }); 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 new file mode 100644 index 000000000000..f3c5d281aefb --- /dev/null +++ b/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts @@ -0,0 +1,211 @@ +import crypto from 'crypto'; + +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const settingsStub = { + get: sinon.stub(), +}; + +const twilioStub = { + validateRequest: sinon.stub(), + isRequestFromTwilio: sinon.stub(), +}; + +const { Twilio } = proxyquire.noCallThru().load('../../../../../../server/services/omnichannel-integrations/providers/twilio.ts', { + '../../../../app/settings/server': { settings: settingsStub }, + '../../../../app/utils/server/restrictions': { fileUploadIsValidContentType: sinon.stub() }, + '../../../lib/i18n': { i18n: sinon.stub() }, + '../../../lib/logger/system': { SystemLogger: { error: sinon.stub() } }, +}); + +/** + * Get a valid Twilio signature for a request + * + * @param {String} authToken your Twilio AuthToken + * @param {String} url your webhook URL + * @param {Object} params the included request parameters + */ +function getSignature(authToken: string, url: string, params: Record): string { + // get all request parameters + const data = Object.keys(params) + // sort them + .sort() + // concatenate them to a string + + .reduce((acc, key) => acc + key + params[key], url); + + return ( + crypto + // sign the string with sha1 using your AuthToken + .createHmac('sha1', authToken) + .update(Buffer.from(data, 'utf-8')) + // base64 encode it + .digest('base64') + ); +} + +describe('Twilio Request Validation', () => { + beforeEach(() => { + settingsStub.get.reset(); + twilioStub.validateRequest.reset(); + twilioStub.isRequestFromTwilio.reset(); + }); + + it('should not validate a request when process.env.TEST_MODE is true', () => { + process.env.TEST_MODE = 'true'; + + const twilio = new Twilio(); + const request = { + headers: { + 'x-twilio-signature': 'test', + }, + }; + + expect(twilio.validateRequest(request)).to.be.true; + }); + + it('should not validate a request when process.env.TEST_MODE is true', () => { + process.env.TEST_MODE = 'true'; + + const twilio = new Twilio(); + const request = { + headers: { + 'x-twilio-signature': 'test', + }, + }; + + expect(twilio.validateRequest(request)).to.be.true; + }); + + it('should validate a request when process.env.TEST_MODE is false', () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }, + body: requestBody, + }; + + expect(twilio.validateRequest(request)).to.be.true; + }); + + it('should reject a request where signature doesnt match', () => { + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + 'x-twilio-signature': getSignature('anotherAuthToken', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }, + body: requestBody, + }; + + expect(twilio.validateRequest(request)).to.be.false; + }); + + it('should reject a request where signature is missing', () => { + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: {}, + body: requestBody, + }; + + expect(twilio.validateRequest(request)).to.be.false; + }); + + it('should reject a request where the signature doesnt correspond body', () => { + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', {}), + }, + body: requestBody, + }; + + expect(twilio.validateRequest(request)).to.be.false; + }); + + it('should return false if URL is not provided', () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns(''); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }, + body: requestBody, + }; + + expect(twilio.validateRequest(request)).to.be.false; + }); + + it('should return false if authToken is not provided', () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns(''); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }, + body: requestBody, + }; + + expect(twilio.validateRequest(request)).to.be.false; + }); +}); diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index 47e76033d71c..a64b8a69036e 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -25,7 +25,8 @@ "@rocket.chat/apps-engine": "1.43.1", "@rocket.chat/icons": "^0.36.0", "@rocket.chat/message-parser": "workspace:^", - "@rocket.chat/ui-kit": "workspace:~" + "@rocket.chat/ui-kit": "workspace:~", + "@types/express": "^4.17.21" }, "volta": { "extends": "../../package.json" diff --git a/packages/core-typings/src/omnichannel/sms.ts b/packages/core-typings/src/omnichannel/sms.ts index 7ff7a4768a13..49364da2b8c3 100644 --- a/packages/core-typings/src/omnichannel/sms.ts +++ b/packages/core-typings/src/omnichannel/sms.ts @@ -1,3 +1,5 @@ +import type { Request } from 'express'; + type ServiceMedia = { url: string; contentType: string; @@ -27,6 +29,7 @@ export interface ISMSProviderConstructor { export interface ISMSProvider { parse(data: unknown): ServiceData; + validateRequest(request: Request): boolean; sendBatch?(from: string, to: string[], message: string): Promise; response(): SMSProviderResponse; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index ee05f10f2c14..b4dc3c52267e 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -527,6 +527,62 @@ const roomsImagesPropsSchema = { export const isRoomsImagesProps = ajv.compile(roomsImagesPropsSchema); +export type RoomsCleanHistoryProps = { + roomId: IRoom['_id']; + latest: string; + oldest: string; + inclusive?: boolean; + excludePinned?: boolean; + filesOnly?: boolean; + users?: IUser['username'][]; + limit?: number; + ignoreDiscussion?: boolean; + ignoreThreads?: boolean; +}; + +const roomsCleanHistorySchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + latest: { + type: 'string', + }, + oldest: { + type: 'string', + }, + inclusive: { + type: 'boolean', + }, + excludePinned: { + type: 'boolean', + }, + filesOnly: { + type: 'boolean', + }, + users: { + type: 'array', + items: { + type: 'string', + }, + }, + limit: { + type: 'number', + }, + ignoreDiscussion: { + type: 'boolean', + }, + ignoreThreads: { + type: 'boolean', + }, + }, + required: ['roomId', 'latest', 'oldest'], + additionalProperties: false, +}; + +export const isRoomsCleanHistoryProps = ajv.compile(roomsCleanHistorySchema); + export type RoomsEndpoints = { '/v1/rooms.autocomplete.channelAndPrivate': { GET: (params: RoomsAutoCompleteChannelAndPrivateProps) => { @@ -559,18 +615,7 @@ export type RoomsEndpoints = { }; '/v1/rooms.cleanHistory': { - POST: (params: { - roomId: IRoom['_id']; - latest: string; - oldest: string; - inclusive?: boolean; - excludePinned?: boolean; - filesOnly?: boolean; - users?: IUser['username'][]; - limit?: number; - ignoreDiscussion?: boolean; - ignoreThreads?: boolean; - }) => { _id: IRoom['_id']; count: number; success: boolean }; + POST: (params: RoomsCleanHistoryProps) => { _id: IRoom['_id']; count: number; success: boolean }; }; '/v1/rooms.createDiscussion': { diff --git a/yarn.lock b/yarn.lock index 95bd95d6bd66..0e40b7f1a28b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8644,6 +8644,7 @@ __metadata: "@rocket.chat/icons": ^0.36.0 "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/ui-kit": "workspace:~" + "@types/express": ^4.17.21 eslint: ~8.45.0 mongodb: ^4.17.2 prettier: ~2.8.8 @@ -13545,6 +13546,18 @@ __metadata: languageName: node linkType: hard +"@types/express@npm:^4.17.21": + version: 4.17.21 + resolution: "@types/express@npm:4.17.21" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^4.17.33 + "@types/qs": "*" + "@types/serve-static": "*" + checksum: fb238298630370a7392c7abdc80f495ae6c716723e114705d7e3fb67e3850b3859bbfd29391463a3fb8c0b32051847935933d99e719c0478710f8098ee7091c5 + languageName: node + linkType: hard + "@types/fibers@npm:^3.1.3": version: 3.1.3 resolution: "@types/fibers@npm:3.1.3"