diff --git a/.changeset/bump-patch-1723645120544.md b/.changeset/bump-patch-1723645120544.md new file mode 100644 index 0000000000000000000000000000000000000000..e1eaa7980afb1bf8e2912d275cc61cb5b6326686 --- /dev/null +++ b/.changeset/bump-patch-1723645120544.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/perfect-dancers-grab.md b/.changeset/perfect-dancers-grab.md new file mode 100644 index 0000000000000000000000000000000000000000..eacb88108a0f7fe974c6fdbea679e4472fe72a82 --- /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 e3296b98ef17847682806892ea1851f0a0f5d19d..ee76dcdd9fe3ad88c606ede275d705bb29a3561f 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 7ae585b89dfa77e39ce8dd5ac7d58246857f161e..6c680b6648d8ad8f9b0ceeaf477e11cbabf5b02d 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -750,6 +750,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 6f8ce64bc6352548af73cea9f01a03176907fa9c..6b2411cf8e3dd8c8e49bac634017909184532bf9 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -97,12 +97,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<string>('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); @@ -121,7 +127,7 @@ API.v1.addRoute('livechat/sms-incoming/:service', { }, source: { type: OmnichannelSourceType.SMS, - alias: this.urlParams.service, + alias: service, }, }; diff --git a/apps/meteor/app/retention-policy/server/cronPruneMessages.ts b/apps/meteor/app/retention-policy/server/cronPruneMessages.ts index 337691bfbe57a08239672c03fa0900d50a5f2594..640aa517a6799dde17d9d6c3f98c5d6e0b8e71a4 100644 --- a/apps/meteor/app/retention-policy/server/cronPruneMessages.ts +++ b/apps/meteor/app/retention-policy/server/cronPruneMessages.ts @@ -6,13 +6,20 @@ import { getCronAdvancedTimerFromPrecisionSetting } from '../../../lib/getCronAd import { cleanRoomHistory } from '../../lib/server/functions/cleanRoomHistory'; import { settings } from '../../settings/server'; -const maxTimes = { - c: 0, - p: 0, - d: 0, +type RetentionRoomTypes = 'c' | 'p' | 'd'; + +const getMaxAgeSettingIdByRoomType = (type: RetentionRoomTypes) => { + switch (type) { + case 'c': + return settings.get<number>('RetentionPolicy_TTL_Channels'); + case 'p': + return settings.get<number>('RetentionPolicy_TTL_Groups'); + case 'd': + return settings.get<number>('RetentionPolicy_TTL_DMs'); + } }; -let types: (keyof typeof maxTimes)[] = []; +let types: RetentionRoomTypes[] = []; const oldest = new Date('0001-01-01T00:00:00Z'); @@ -29,7 +36,7 @@ async function job(): Promise<void> { // get all rooms with default values for await (const type of types) { - const maxAge = maxTimes[type] || 0; + const maxAge = getMaxAgeSettingIdByRoomType(type) || 0; const latest = new Date(now.getTime() - maxAge); const rooms = await Rooms.find( @@ -95,9 +102,6 @@ settings.watchMultiple( 'RetentionPolicy_AppliesToChannels', 'RetentionPolicy_AppliesToGroups', 'RetentionPolicy_AppliesToDMs', - 'RetentionPolicy_TTL_Channels', - 'RetentionPolicy_TTL_Groups', - 'RetentionPolicy_TTL_DMs', 'RetentionPolicy_Advanced_Precision', 'RetentionPolicy_Advanced_Precision_Cron', 'RetentionPolicy_Precision', @@ -120,10 +124,6 @@ settings.watchMultiple( types.push('d'); } - maxTimes.c = settings.get<number>('RetentionPolicy_TTL_Channels'); - maxTimes.p = settings.get<number>('RetentionPolicy_TTL_Groups'); - maxTimes.d = settings.get<number>('RetentionPolicy_TTL_DMs'); - const precision = (settings.get<boolean>('RetentionPolicy_Advanced_Precision') && settings.get<string>('RetentionPolicy_Advanced_Precision_Cron')) || getCronAdvancedTimerFromPrecisionSetting(settings.get('RetentionPolicy_Precision')); diff --git a/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx b/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx index 19bef201492f200459d1d966acc8019202a6283a..79d2fbe11140392dababc5ab6ab1ec4815f7be6f 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 <Field> <FieldLabel>{t('Client_Secret')}</FieldLabel> <FieldRow> - <TextInput value={data.clientSecret} /> + <PasswordInput value={data.clientSecret} /> </FieldRow> </Field> <Field> diff --git a/apps/meteor/ee/server/lib/audit/methods.ts b/apps/meteor/ee/server/lib/audit/methods.ts index add64414c6e8bdb7784054b1344cd4c999d6a465..b221f4a2f1e14c80671bca7308ca7916243395e2 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<ServerMethods>({ 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<ServerMethods>({ 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<ServerMethods>({ 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<ServerMethods>({ 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<ServerMethods>({ 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 ab000b142225676868a0e83078cee8aa06f426a8..f0efcc04539d9b64115d38de1cd2f325550956f8 100644 --- a/apps/meteor/server/lib/ldap/Manager.ts +++ b/apps/meteor/server/lib/ldap/Manager.ts @@ -165,7 +165,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 05ac5aaf7e7bbfa6cd56af869c5139b0ed7c9ce8..14ef69189b337be0ebfd230bd3b6bcba2381cbe7 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<ServerMethods>({ 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 8c7d056532d3958d3b54f3990b0336c853f41fb5..9ed7e0b99da28df36e2dbe113fdc42e7e7031f5f 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<ServerMethods>({ } }, }); + +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 19284b8e1e6b3a25a33cc1d8e8a4b38b89ffb4de..d036345663cd403cf8534026ad961cd5c15cc223 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 3aff869a95a0c8c85cd6340372a40ce3e0dd7579..4a1c8d9d0ebf51c8e035f50a60bf793a037fd1ce 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<string>('SMS_Twilio_authToken'); + const siteUrl = settings.get<string>('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 3e78907bbf754d4a298f1b7cc492fa1f3e665012..aa42bacad624321125211d88c3ba469860f1db1a 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 6ec6e33556559bdc0d76f8d178bfa325cd65a92f..520af87d2333a64f1a1473a918d5565119753000 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/methods.ts b/apps/meteor/tests/end-to-end/api/methods.ts index e1ca05d1fbf216f56766479d3385b7ebf3adabc7..197a71f8e8f267ddeccc6677d03f165c797caa91 100644 --- a/apps/meteor/tests/end-to-end/api/methods.ts +++ b/apps/meteor/tests/end-to-end/api/methods.ts @@ -2030,6 +2030,13 @@ describe('Meteor.methods', () => { let messageWithMarkdownId: IMessage['_id']; let channelName: string; const siteUrl = process.env.SITE_URL || process.env.TEST_API_URL || 'http://localhost:3000'; + let testUser: TestUser<IUser>; + let testUserCredentials: Credentials; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); before('create room', (done) => { channelName = `methods-test-channel-${Date.now()}`; @@ -2125,13 +2132,14 @@ describe('Meteor.methods', () => { 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) => { - void request + it('should update a message with a URL', async () => { + await request .post(methodCall('updateMessage')) .set(credentials) .send({ @@ -2149,8 +2157,53 @@ describe('Meteor.methods', () => { 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 () => { @@ -3295,4 +3348,107 @@ describe('Meteor.methods', () => { .end(done); }); }); + (IS_EE ? describe : describe.skip)('[@auditGetAuditions] EE', () => { + let testUser: TestUser<IUser>; + let testUserCredentials: Credentials; + + 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: any) => { + 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/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index a8d64b1f4c7d72fbdf9a2c13cc0da19be8e04145..b502bca6add821d2ff491c513fca743ecfb7a95d 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -1024,7 +1024,6 @@ describe('[Rooms]', () => { .end(done); }); }); - describe('[/rooms.info]', () => { let testChannel: IRoom; let testGroup: IRoom; diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index d6112dd2416bbc5615aa3d3e54fb18733f212ade..ea2aa5baa99ba3ea9a465db1cff8a91e51e4ab6e 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -2624,18 +2624,37 @@ describe('[Users]', () => { }); 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) => { - 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); + 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/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 0000000000000000000000000000000000000000..f3c5d281aefbcdb9ef27ce9ac4cf5d3c9487857e --- /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, any>): 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 a9f4fdd293e309d564e3f6b4f28dfe9317048a36..3c0c97198b7f854bf09aebfb638879a9b9f66543 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -25,7 +25,8 @@ "@rocket.chat/apps-engine": "1.44.0", "@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 7ff7a4768a134fa11aa12f1f45cad9a5831a9966..49364da2b8c3630f08afe8806e087c085476ab05 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<SMSProviderResult>; response(): SMSProviderResponse; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 1c0b6a360f7b6052cd1938242d3c863f02f17f5e..868fd5850157f90b3b08b4ead861d01c156d8075 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<RoomsImagesProps>(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<RoomsCleanHistoryProps>(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 a04c45ee1f6442fea3ea0b002a8d6a127014978d..84ca676c886c9f533a674c1f8907d46c2d6f1e4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8645,6 +8645,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 @@ -8968,10 +8969,10 @@ __metadata: "@rocket.chat/icons": "*" "@rocket.chat/prettier-config": "*" "@rocket.chat/styled": "*" - "@rocket.chat/ui-avatar": 5.0.0-rc.6 - "@rocket.chat/ui-contexts": 9.0.0-rc.6 - "@rocket.chat/ui-kit": 0.36.0-rc.0 - "@rocket.chat/ui-video-conf": 9.0.0-rc.6 + "@rocket.chat/ui-avatar": 5.0.0 + "@rocket.chat/ui-contexts": 9.0.0 + "@rocket.chat/ui-kit": 0.36.0 + "@rocket.chat/ui-video-conf": 9.0.0 "@tanstack/react-query": "*" react: "*" react-dom: "*" @@ -9060,8 +9061,8 @@ __metadata: "@rocket.chat/fuselage-tokens": "*" "@rocket.chat/message-parser": 0.31.29 "@rocket.chat/styled": "*" - "@rocket.chat/ui-client": 9.0.0-rc.6 - "@rocket.chat/ui-contexts": 9.0.0-rc.6 + "@rocket.chat/ui-client": 9.0.0 + "@rocket.chat/ui-contexts": 9.0.0 katex: "*" react: "*" languageName: unknown @@ -10281,7 +10282,7 @@ __metadata: typescript: ~5.3.3 peerDependencies: "@rocket.chat/fuselage": "*" - "@rocket.chat/ui-contexts": 9.0.0-rc.6 + "@rocket.chat/ui-contexts": 9.0.0 react: ~17.0.2 languageName: unknown linkType: soft @@ -10334,7 +10335,7 @@ __metadata: "@rocket.chat/fuselage": "*" "@rocket.chat/fuselage-hooks": "*" "@rocket.chat/icons": "*" - "@rocket.chat/ui-contexts": 9.0.0-rc.6 + "@rocket.chat/ui-contexts": 9.0.0 react: ~17.0.2 languageName: unknown linkType: soft @@ -10510,8 +10511,8 @@ __metadata: "@rocket.chat/fuselage-hooks": "*" "@rocket.chat/icons": "*" "@rocket.chat/styled": "*" - "@rocket.chat/ui-avatar": 5.0.0-rc.6 - "@rocket.chat/ui-contexts": 9.0.0-rc.6 + "@rocket.chat/ui-avatar": 5.0.0 + "@rocket.chat/ui-contexts": 9.0.0 react: ^17.0.2 react-dom: ^17.0.2 languageName: unknown @@ -10600,8 +10601,8 @@ __metadata: typescript: ~5.3.3 peerDependencies: "@rocket.chat/layout": "*" - "@rocket.chat/tools": 0.2.2-rc.0 - "@rocket.chat/ui-contexts": 9.0.0-rc.6 + "@rocket.chat/tools": 0.2.2 + "@rocket.chat/ui-contexts": 9.0.0 "@tanstack/react-query": "*" react: "*" react-hook-form: "*" @@ -13548,6 +13549,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"