diff --git a/.changeset/many-rules-shout.md b/.changeset/many-rules-shout.md new file mode 100644 index 000000000000..eacb88108a0f --- /dev/null +++ b/.changeset/many-rules-shout.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/authorization/server/functions/canDeleteMessage.ts b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts index 91769e71270a..3461c35e98b1 100644 --- a/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts +++ b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts @@ -1,7 +1,8 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, IRoom } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import { getValue } from '../../../settings/server/raw'; +import { canAccessRoomAsync } from './canAccessRoom'; import { hasPermissionAsync } from './hasPermission'; const elapsedTime = (ts: Date): number => { @@ -13,6 +14,25 @@ export const canDeleteMessageAsync = async ( uid: string, { u, rid, ts }: { u: Pick; rid: string; ts: Date }, ): Promise => { + const room = await Rooms.findOneById>(rid, { + projection: { + _id: 1, + ro: 1, + unmuted: 1, + t: 1, + teamId: 1, + prid: 1, + }, + }); + + if (!room) { + return false; + } + + if (!(await canAccessRoomAsync(room, { _id: uid }))) { + return false; + } + const forceDelete = await hasPermissionAsync(uid, 'force-delete-message', rid); if (forceDelete) { @@ -45,12 +65,6 @@ export const canDeleteMessageAsync = async ( } } - const room = await Rooms.findOneById(rid, { projection: { ro: 1, unmuted: 1 } }); - - if (!room) { - return false; - } - if (room.ro === true && !(await hasPermissionAsync(uid, 'post-readonly', rid))) { // Unless the user was manually unmuted if (u.username && !(room.unmuted || []).includes(u.username)) { diff --git a/apps/meteor/app/otr/server/methods/updateOTRAck.ts b/apps/meteor/app/otr/server/methods/updateOTRAck.ts index a5a502ddf1a2..cff7c35f8269 100644 --- a/apps/meteor/app/otr/server/methods/updateOTRAck.ts +++ b/apps/meteor/app/otr/server/methods/updateOTRAck.ts @@ -1,8 +1,12 @@ import { api } from '@rocket.chat/core-services'; import type { IOTRMessage } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; +import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; + declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -11,10 +15,40 @@ declare module '@rocket.chat/ui-contexts' { } Meteor.methods({ - updateOTRAck({ message, ack }) { - if (!Meteor.userId()) { + async updateOTRAck({ message, ack }) { + const uid = Meteor.userId(); + if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateOTRAck' }); } + + check(ack, String); + check(message, { + _id: String, + rid: String, + msg: String, + t: String, + ts: Date, + u: { + _id: String, + username: String, + name: String, + }, + }); + + if (message?.t !== 'otr') { + throw new Meteor.Error('error-invalid-message', 'Invalid message type', { method: 'updateOTRAck' }); + } + + const room = await Rooms.findOneByIdAndType(message.rid, 'd', { projection: { t: 1, _id: 1, uids: 1 } }); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'updateOTRAck' }); + } + + if (!(await canAccessRoomAsync(room, { _id: uid })) || (room.uids && (!message.u._id || !room.uids.includes(message.u._id)))) { + throw new Meteor.Error('error-invalid-user', 'Invalid user, not in room', { method: 'updateOTRAck' }); + } + const acknowledgeMessage: IOTRMessage = { ...message, otrAck: ack }; void api.broadcast('otrAckUpdate', { roomId: message.rid, acknowledgeMessage }); }, diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppDetails/AppDetails.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppDetails/AppDetails.tsx index 8d17f669db83..5f3e427b8391 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppDetails/AppDetails.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppDetails/AppDetails.tsx @@ -2,10 +2,12 @@ import { Box, Callout, Chip, Margins } from '@rocket.chat/fuselage'; import { ExternalLink } from '@rocket.chat/ui-client'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import DOMPurify from 'dompurify'; import React from 'react'; import ScreenshotCarouselAnchor from '../../../components/ScreenshotCarouselAnchor'; import type { AppInfo } from '../../../definitions/AppInfo'; +import { purifyOptions } from '../../../lib/purifyOptions'; import AppDetailsAPIs from './AppDetailsAPIs'; import { normalizeUrl } from './normalizeUrl'; @@ -61,7 +63,14 @@ const AppDetails = ({ app }: AppDetailsProps) => { {t('Description')} - + diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppReleases/AppReleasesItem.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppReleases/AppReleasesItem.tsx index bc27053ea1d2..974b6d148e61 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppReleases/AppReleasesItem.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppReleases/AppReleasesItem.tsx @@ -1,9 +1,11 @@ import { Accordion, Box } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import DOMPurify from 'dompurify'; import type { ReactElement } from 'react'; import React from 'react'; import { useTimeAgo } from '../../../../../hooks/useTimeAgo'; +import { purifyOptions } from '../../../lib/purifyOptions'; type IRelease = { version: string; @@ -36,7 +38,7 @@ const AppReleasesItem = ({ release, ...props }: ReleaseItemProps): ReactElement return ( {release.detailedChangelog?.rendered ? ( - + ) : ( {t('No_release_information_provided')} )} diff --git a/apps/meteor/client/views/marketplace/lib/purifyOptions.ts b/apps/meteor/client/views/marketplace/lib/purifyOptions.ts new file mode 100644 index 000000000000..cef1a2c8c707 --- /dev/null +++ b/apps/meteor/client/views/marketplace/lib/purifyOptions.ts @@ -0,0 +1,50 @@ +export const purifyOptions = { + ALLOWED_TAGS: [ + 'b', + 'i', + 'em', + 'strong', + 'br', + 'p', + 'ul', + 'ol', + 'li', + 'article', + 'aside', + 'figure', + 'section', + 'summary', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hgroup', + 'div', + 'hr', + 'span', + 'wbr', + 'abbr', + 'acronym', + 'cite', + 'code', + 'dfn', + 'figcaption', + 'mark', + 's', + 'samp', + 'sub', + 'sup', + 'var', + 'time', + 'q', + 'del', + 'ins', + 'rp', + 'rt', + 'ruby', + 'bdi', + 'bdo', + ], +}; diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index e0466b48c114..6ff896cb70b7 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -900,6 +900,10 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.findOne(query, options); } + findOneByIdAndType(roomId: IRoom['_id'], type: IRoom['t'], options: FindOptions = {}): Promise { + return this.findOne({ _id: roomId, t: type }, options); + } + setCallStatus(_id: IRoom['_id'], status: IRoom['callStatus']): Promise { const query: Filter = { _id, 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..eb7e85d82e38 100644 --- a/apps/meteor/tests/end-to-end/api/24-methods.js +++ b/apps/meteor/tests/end-to-end/api/24-methods.js @@ -2542,6 +2542,167 @@ describe('Meteor.methods', function () { updateSetting('Message_AllowEditing_BlockEditInMinutes', 0), ]); }); + + describe('message deletion when user is not part of the room', () => { + let ridTestRoom; + let messageIdTestRoom; + let testUser; + let testUserCredentials; + + before('create room, add new owner, and leave room', async () => { + await updatePermission('leave-p', ['admin', 'user']); + await updatePermission('leave-c', ['admin', 'user']); + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + const channelName = `methods-test-channel-${Date.now()}`; + + await request + .post(api('groups.create')) + .set(testUserCredentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + ridTestRoom = res.body.group._id; + }); + + await request + .post(methodCall('sendMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: `${Date.now() + Math.random()}`, + rid: ridTestRoom, + msg: 'just a random test message', + }, + ], + 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('result').that.is.an('object'); + messageIdTestRoom = data.result._id; + }); + + await request + .post(methodCall('addUsersToRoom')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [ + { + rid: ridTestRoom, + users: ['rocket.cat'], + }, + ], + 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'); + }); + + await request + .post(api('groups.addOwner')) + .set(testUserCredentials) + .send({ + roomId: ridTestRoom, + userId: 'rocket.cat', + }) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }); + + await request + .post(api('groups.leave')) + .set(testUserCredentials) + .send({ + roomId: ridTestRoom, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }); + }); + + it('should not delete a message if the user is no longer member of the room', async () => { + await request + .post(methodCall('deleteMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'deleteMessage', + params: [{ _id: messageIdTestRoom, rid: ridTestRoom }], + 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', 'result'); + expect(data).to.have.a.property('id', 'id'); + expect(data.error).to.have.a.property('error', 'error-action-not-allowed'); + }); + }); + + it('should not delete a message if the user was never part of the room', async () => { + await request + .post(methodCall('deleteMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'deleteMessage', + params: [{ _id: messageIdTestRoom, rid: ridTestRoom }], + 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', 'result'); + expect(data).to.have.a.property('id', 'id'); + expect(data.error).to.have.a.property('error', 'error-action-not-allowed'); + }); + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'p', roomId: ridTestRoom }), + deleteUser(testUser), + updatePermission('leave-p', ['admin']), + updatePermission('leave-c', ['admin']), + ]), + ); + }); }); describe('[@setUserActiveStatus]', () => { @@ -3288,4 +3449,305 @@ describe('Meteor.methods', function () { .end(done); }); }); + + describe('UpdateOTRAck', () => { + let testUser; + let testUser2; + let testUserCredentials; + let dmTestId; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + before('create direct conversation between both users', (done) => { + void request + .post(methodCall('createDirectMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'createDirectMessage', + params: [testUser2.username], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + const result = JSON.parse(res.body.message); + expect(result.result).to.be.an('object'); + expect(result.result).to.have.property('rid').that.is.an('string'); + + dmTestId = result.result.rid; + done(); + }); + }); + + after(() => Promise.all([deleteRoom({ type: 'd', roomId: dmTestId }), deleteUser(testUser), deleteUser(testUser2)])); + + it('should fail if required parameters are not present', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + // rid: 'test', + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + 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('message', "Match error: Missing key 'rid'"); + }); + }); + + it('should fail if required parameters have a different type', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: { $ne: 'test' }, + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + 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('message', 'Match error: Expected string, got object in field rid'); + }); + }); + + it('should fail if "t" is not "otr"', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: 'test', + msg: 'test', + t: 'notOTR', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + 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('message', 'Invalid message type [error-invalid-message]'); + }); + }); + + it('should fail if room does not exist', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: 'test', + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + 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('message', 'Invalid room [error-invalid-room]'); + }); + }); + + it('should fail if room is not a DM', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: 'GENERAL', + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + 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('message', 'Invalid room [error-invalid-room]'); + }); + }); + + it('should fail if user is not part of DM room', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: dmTestId, + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: testUser._id, + username: testUser.username, + name: 'test', + }, + }, + ack: 'test', + }, + ], + 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('message', 'Invalid user, not in room [error-invalid-user]'); + }); + }); + + it('should pass if all parameters are present and user is part of DM room', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: dmTestId, + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: testUser._id, + username: testUser.username, + name: 'test', + }, + }, + ack: 'test', + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + expect(res.body).to.have.a.property('success', true); + }); + }); + }); }); diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index 8535f471bc0f..398af6f19955 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -77,7 +77,9 @@ "react": "*" }, "dependencies": { + "@types/dompurify": "^3.0.5", "date-fns": "^3.3.1", + "dompurify": "^3.1.6", "highlight.js": "^11.5.1", "react-error-boundary": "^3.1.4" }, diff --git a/packages/gazzodown/src/emoji/EmojiRenderer.tsx b/packages/gazzodown/src/emoji/EmojiRenderer.tsx index 7a4ca5324930..84116361157c 100644 --- a/packages/gazzodown/src/emoji/EmojiRenderer.tsx +++ b/packages/gazzodown/src/emoji/EmojiRenderer.tsx @@ -1,5 +1,6 @@ import { MessageEmoji, ThreadMessageEmoji } from '@rocket.chat/fuselage'; import type * as MessageParser from '@rocket.chat/message-parser'; +import DOMPurify from 'dompurify'; import { ReactElement, useMemo, useContext, memo } from 'react'; import { MarkupInteractionContext } from '../MarkupInteractionContext'; @@ -14,10 +15,12 @@ const EmojiRenderer = ({ big = false, preview = false, ...emoji }: EmojiProps): const fallback = useMemo(() => ('unicode' in emoji ? emoji.unicode : `:${emoji.shortCode ?? emoji.value.value}:`), [emoji]); + const sanitizedFallback = DOMPurify.sanitize(fallback); + const descriptors = useMemo(() => { - const detected = detectEmoji?.(fallback); + const detected = detectEmoji?.(sanitizedFallback); return detected?.length !== 0 ? detected : undefined; - }, [detectEmoji, fallback]); + }, [detectEmoji, sanitizedFallback]); return ( <> @@ -34,8 +37,8 @@ const EmojiRenderer = ({ big = false, preview = false, ...emoji }: EmojiProps): )} )) ?? ( - - {fallback} + + {sanitizedFallback} )} diff --git a/packages/gazzodown/src/katex/KatexBlock.tsx b/packages/gazzodown/src/katex/KatexBlock.tsx index 25e9c77dc863..f3a03bd668d0 100644 --- a/packages/gazzodown/src/katex/KatexBlock.tsx +++ b/packages/gazzodown/src/katex/KatexBlock.tsx @@ -15,6 +15,7 @@ const KatexBlock = ({ code }: KatexBlockProps): ReactElement => { macros: { '\\href': '\\@secondoftwo', }, + maxSize: 100, }), [code], ); diff --git a/packages/gazzodown/src/katex/KatexElement.tsx b/packages/gazzodown/src/katex/KatexElement.tsx index 3595f698f7ae..099c2f82cf8c 100644 --- a/packages/gazzodown/src/katex/KatexElement.tsx +++ b/packages/gazzodown/src/katex/KatexElement.tsx @@ -15,6 +15,7 @@ const KatexElement = ({ code }: KatexElementProps): ReactElement => { macros: { '\\href': '\\@secondoftwo', }, + maxSize: 100, }), [code], ); diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 173a373c7df8..231dce5cd06d 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -198,6 +198,7 @@ export interface IRoomsModel extends IBaseModel { setE2eKeyId(roomId: string, e2eKeyId: string, options?: FindOptions): Promise; findOneByImportId(importId: string, options?: FindOptions): Promise; findOneByNameAndNotId(name: string, rid: string): Promise; + findOneByIdAndType(roomId: IRoom['_id'], type: IRoom['t'], options?: FindOptions): Promise; findOneByDisplayName(displayName: string, options?: FindOptions): Promise; findOneByNameAndType( name: string,