From 53555ad72aae884f1472b89d3a426f719df2fede Mon Sep 17 00:00:00 2001 From: Srinjoyee_Dey Date: Thu, 5 Feb 2026 23:02:57 +0530 Subject: [PATCH 1/4] fix(security): escape external inputs in LivechatRooms regex queries --- packages/models/src/models/LivechatRooms.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/models/src/models/LivechatRooms.ts b/packages/models/src/models/LivechatRooms.ts index d388e7a39fd6e..de82449523ef6 100644 --- a/packages/models/src/models/LivechatRooms.ts +++ b/packages/models/src/models/LivechatRooms.ts @@ -1330,8 +1330,9 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive query.tags = { $in: tags }; } if (customFields && Object.keys(customFields).length) { + // Escape custom field values to prevent query failures and ReDoS query.$and = Object.keys(customFields).map((key) => ({ - [`livechatData.${key}`]: new RegExp(customFields[key], 'i'), + [`livechatData.${key}`]: new RegExp(escapeRegExp(customFields[key]), 'i'), })); } @@ -1827,7 +1828,11 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive const query: Filter = { 't': 'l', 'v.token': visitorToken, - '$or': [{ 'email.thread': { $elemMatch: { $in: emailThread } } }, { 'email.thread': new RegExp(emailThread.join('|')) }], + '$or': [ + { 'email.thread': { $elemMatch: { $in: emailThread } } }, + // Escape email thread IDs to prevent query failures and ReDoS + { 'email.thread': new RegExp(emailThread.map((t) => escapeRegExp(t)).join('|')) }, + ], }; return this.findOne(query, options); @@ -1844,7 +1849,8 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive 'v.token': visitorToken, '$or': [ { 'email.thread': { $elemMatch: { $in: emailThread } } }, - { 'email.thread': new RegExp(emailThread.map((t) => `"${t}"`).join('|')) }, + // Escape email thread IDs to prevent query failures and ReDoS + { 'email.thread': new RegExp(emailThread.map((t) => `"${escapeRegExp(t)}"`).join('|')) }, ], ...(departmentId && { departmentId }), }; @@ -1857,7 +1863,11 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive 't': 'l', 'open': true, 'v.token': visitorToken, - '$or': [{ 'email.thread': { $elemMatch: { $in: emailThread } } }, { 'email.thread': new RegExp(emailThread.join('|')) }], + '$or': [ + { 'email.thread': { $elemMatch: { $in: emailThread } } }, + // Escape email thread IDs to prevent query failures and ReDoS + { 'email.thread': new RegExp(emailThread.map((t) => escapeRegExp(t)).join('|')) }, + ], }; return this.findOne(query, options); From dd9088efd7fcf3a363b208c7a6f3405863ef86de Mon Sep 17 00:00:00 2001 From: Srinjoyee_Dey Date: Thu, 5 Feb 2026 23:29:22 +0530 Subject: [PATCH 2/4] security: fix regex vulnerabilities and standardize usage in models --- packages/models/src/models/LivechatRooms.ts | 2 +- .../models/src/models/ModerationReports.ts | 24 ++++++++++--------- packages/models/src/models/Sessions.ts | 6 +++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/models/src/models/LivechatRooms.ts b/packages/models/src/models/LivechatRooms.ts index de82449523ef6..c8927c9f0318f 100644 --- a/packages/models/src/models/LivechatRooms.ts +++ b/packages/models/src/models/LivechatRooms.ts @@ -1850,7 +1850,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive '$or': [ { 'email.thread': { $elemMatch: { $in: emailThread } } }, // Escape email thread IDs to prevent query failures and ReDoS - { 'email.thread': new RegExp(emailThread.map((t) => `"${escapeRegExp(t)}"`).join('|')) }, + { 'email.thread': new RegExp(emailThread.map((t) => escapeRegExp(t)).join('|')) }, ], ...(departmentId && { departmentId }), }; diff --git a/packages/models/src/models/ModerationReports.ts b/packages/models/src/models/ModerationReports.ts index 196b5161aef5c..acb715c55491c 100644 --- a/packages/models/src/models/ModerationReports.ts +++ b/packages/models/src/models/ModerationReports.ts @@ -9,6 +9,8 @@ import type { import type { FindPaginated, IModerationReportsModel, PaginationParams } from '@rocket.chat/model-typings'; import type { AggregationCursor, Collection, Db, Document, FindCursor, FindOptions, IndexDescription, UpdateResult } from 'mongodb'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; + import { BaseRaw } from './BaseRaw'; import { readSecondaryPreferred } from '../readSecondaryPreferred'; @@ -239,11 +241,11 @@ export class ModerationReportsRaw extends BaseRaw implements const fuzzyQuery = selector ? { - 'message.msg': { - $regex: selector, - $options: 'i', - }, - } + 'message.msg': { + $regex: escapeRegExp(selector), + $options: 'i', + }, + } : {}; const params = { @@ -388,25 +390,25 @@ export class ModerationReportsRaw extends BaseRaw implements $or: [ { 'message.msg': { - $regex: selector, + $regex: escapeRegExp(selector), $options: 'i', }, }, { description: { - $regex: selector, + $regex: escapeRegExp(selector), $options: 'i', }, }, { 'message.u.username': { - $regex: selector, + $regex: escapeRegExp(selector), $options: 'i', }, }, { 'message.u.name': { - $regex: selector, + $regex: escapeRegExp(selector), $options: 'i', }, }, @@ -424,13 +426,13 @@ export class ModerationReportsRaw extends BaseRaw implements $or: [ { 'reportedUser.username': { - $regex: selector, + $regex: escapeRegExp(selector), $options: 'i', }, }, { 'reportedUser.name': { - $regex: selector, + $regex: escapeRegExp(selector), $options: 'i', }, }, diff --git a/packages/models/src/models/Sessions.ts b/packages/models/src/models/Sessions.ts index 3763bf38b457a..eb481d806418f 100644 --- a/packages/models/src/models/Sessions.ts +++ b/packages/models/src/models/Sessions.ts @@ -28,6 +28,8 @@ import type { FindOptions, } from 'mongodb'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; + import { getCollectionName } from '../index'; import { BaseRaw } from './BaseRaw'; import { readSecondaryPreferred } from '../readSecondaryPreferred'; @@ -755,7 +757,7 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { offset?: number; count?: number; }): Promise> { - const searchQuery = search ? [{ searchTerm: { $regex: search, $options: 'i' } }] : []; + const searchQuery = search ? [{ searchTerm: { $regex: escapeRegExp(search), $options: 'i' } }] : []; const matchOperator = { $match: { @@ -861,7 +863,7 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { offset?: number; count?: number; }): Promise> { - const searchQuery = search ? [{ searchTerm: { $regex: search, $options: 'i' } }] : []; + const searchQuery = search ? [{ searchTerm: { $regex: escapeRegExp(search), $options: 'i' } }] : []; const matchOperator = { $match: { From 694277d7a3709a4bff534b9357f8655137cd1179 Mon Sep 17 00:00:00 2001 From: Srinjoyee_Dey Date: Thu, 5 Feb 2026 23:37:54 +0530 Subject: [PATCH 3/4] security: strip inline comments from implementation --- packages/models/src/models/LivechatRooms.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/models/src/models/LivechatRooms.ts b/packages/models/src/models/LivechatRooms.ts index c8927c9f0318f..eabcf3a50aa86 100644 --- a/packages/models/src/models/LivechatRooms.ts +++ b/packages/models/src/models/LivechatRooms.ts @@ -1849,7 +1849,6 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive 'v.token': visitorToken, '$or': [ { 'email.thread': { $elemMatch: { $in: emailThread } } }, - // Escape email thread IDs to prevent query failures and ReDoS { 'email.thread': new RegExp(emailThread.map((t) => escapeRegExp(t)).join('|')) }, ], ...(departmentId && { departmentId }), From 548d44f3349c1fa7722f6ca2bf3ba7a9f07172a9 Mon Sep 17 00:00:00 2001 From: Srinjoyee_Dey Date: Tue, 24 Feb 2026 10:11:32 +0530 Subject: [PATCH 4/4] test: revert double escaping inside models and add verification unit tests --- .../src/models/ModerationReports.spec.ts | 66 +++++++++++++++++++ .../models/src/models/ModerationReports.ts | 16 ++--- packages/models/src/models/Sessions.spec.ts | 50 ++++++++++++++ packages/models/src/models/Sessions.ts | 6 +- 4 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 packages/models/src/models/ModerationReports.spec.ts create mode 100644 packages/models/src/models/Sessions.spec.ts diff --git a/packages/models/src/models/ModerationReports.spec.ts b/packages/models/src/models/ModerationReports.spec.ts new file mode 100644 index 0000000000000..59a497068dd33 --- /dev/null +++ b/packages/models/src/models/ModerationReports.spec.ts @@ -0,0 +1,66 @@ +import { ModerationReportsRaw } from './ModerationReports'; + +describe('ModerationReportsRaw', () => { + it('should pass selector to $regex unchanged in findReportedMessagesByReportedUserId', async () => { + const mockCollection = { + find: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]), _bsonType: 'Cursor' }), + countDocuments: jest.fn().mockResolvedValue(0), + collectionName: 'moderation_reports', + createIndexes: jest.fn().mockResolvedValue([]), + } as any; + const mockDb = { + collection: jest.fn().mockReturnValue(mockCollection), + } as any; + + const moderationReportsRaw = new ModerationReportsRaw(mockDb); + + await moderationReportsRaw.findReportedMessagesByReportedUserId('123', '.*', { offset: 0, count: 10 }); + + expect(mockCollection.find).toHaveBeenCalled(); + const query = mockCollection.find.mock.calls[0][0]; + + expect(query['message.msg'].$regex).toBe('.*'); + }); + + it('should pass selector to $regex unchanged in countMessageReportsInRange (getSearchQueryForSelector)', () => { + const mockCollection = { + countDocuments: jest.fn().mockResolvedValue(0), + collectionName: 'moderation_reports', + createIndexes: jest.fn().mockResolvedValue([]), + } as any; + const mockDb = { + collection: jest.fn().mockReturnValue(mockCollection), + } as any; + + const moderationReportsRaw = new ModerationReportsRaw(mockDb); + + moderationReportsRaw.countMessageReportsInRange(new Date(), new Date(), '.*'); + + expect(mockCollection.countDocuments).toHaveBeenCalled(); + const query = mockCollection.countDocuments.mock.calls[0][0]; + + expect(query.$or[0]['message.msg'].$regex).toBe('.*'); + expect(query.$or[1].description.$regex).toBe('.*'); + }); + + it('should pass selector to $regex unchanged via getTotalUniqueReportedUsers (getSearchQueryForSelectorUsers)', async () => { + const mockCollection = { + aggregate: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }), + collectionName: 'moderation_reports', + createIndexes: jest.fn().mockResolvedValue([]), + } as any; + const mockDb = { + collection: jest.fn().mockReturnValue(mockCollection), + } as any; + + const moderationReportsRaw = new ModerationReportsRaw(mockDb); + + await moderationReportsRaw.getTotalUniqueReportedUsers(new Date(), new Date(), '.*', false); + + expect(mockCollection.aggregate).toHaveBeenCalled(); + const pipeline = mockCollection.aggregate.mock.calls[0][0]; + const matchStage = pipeline.find((stage: any) => stage.$match); + + expect(matchStage.$match.$or[0]['reportedUser.username'].$regex).toBe('.*'); + }); +}); diff --git a/packages/models/src/models/ModerationReports.ts b/packages/models/src/models/ModerationReports.ts index acb715c55491c..0c19ffdf89853 100644 --- a/packages/models/src/models/ModerationReports.ts +++ b/packages/models/src/models/ModerationReports.ts @@ -9,8 +9,6 @@ import type { import type { FindPaginated, IModerationReportsModel, PaginationParams } from '@rocket.chat/model-typings'; import type { AggregationCursor, Collection, Db, Document, FindCursor, FindOptions, IndexDescription, UpdateResult } from 'mongodb'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; - import { BaseRaw } from './BaseRaw'; import { readSecondaryPreferred } from '../readSecondaryPreferred'; @@ -242,7 +240,7 @@ export class ModerationReportsRaw extends BaseRaw implements const fuzzyQuery = selector ? { 'message.msg': { - $regex: escapeRegExp(selector), + $regex: selector, $options: 'i', }, } @@ -390,25 +388,25 @@ export class ModerationReportsRaw extends BaseRaw implements $or: [ { 'message.msg': { - $regex: escapeRegExp(selector), + $regex: selector, $options: 'i', }, }, { description: { - $regex: escapeRegExp(selector), + $regex: selector, $options: 'i', }, }, { 'message.u.username': { - $regex: escapeRegExp(selector), + $regex: selector, $options: 'i', }, }, { 'message.u.name': { - $regex: escapeRegExp(selector), + $regex: selector, $options: 'i', }, }, @@ -426,13 +424,13 @@ export class ModerationReportsRaw extends BaseRaw implements $or: [ { 'reportedUser.username': { - $regex: escapeRegExp(selector), + $regex: selector, $options: 'i', }, }, { 'reportedUser.name': { - $regex: escapeRegExp(selector), + $regex: selector, $options: 'i', }, }, diff --git a/packages/models/src/models/Sessions.spec.ts b/packages/models/src/models/Sessions.spec.ts new file mode 100644 index 0000000000000..e15bb868fd0bf --- /dev/null +++ b/packages/models/src/models/Sessions.spec.ts @@ -0,0 +1,50 @@ +import { SessionsRaw } from './Sessions'; + +describe('SessionsRaw', () => { + it('should pass search parameter to $regex unchanged in aggregateSessionsByUserId', async () => { + const mockCollection = { + aggregate: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }), + collectionName: 'sessions', + createIndexes: jest.fn().mockResolvedValue([]), + } as any; + const mockDb = { + collection: jest.fn().mockReturnValue(mockCollection), + } as any; + + const sessionsRaw = new SessionsRaw(mockDb); + + await sessionsRaw.aggregateSessionsByUserId({ + uid: '123', + search: '.*', // raw regex that should not be escaped + }); + + expect(mockCollection.aggregate).toHaveBeenCalled(); + const pipeline = mockCollection.aggregate.mock.calls[0][0]; + const matchStage = pipeline.find((stage: any) => stage.$match); + + expect(matchStage.$match.$and[0]).toEqual({ searchTerm: { $regex: '.*', $options: 'i' } }); + }); + + it('should pass search parameter to $regex unchanged in aggregateSessionsAndPopulate', async () => { + const mockCollection = { + aggregate: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }), + collectionName: 'sessions', + createIndexes: jest.fn().mockResolvedValue([]), + } as any; + const mockDb = { + collection: jest.fn().mockReturnValue(mockCollection), + } as any; + + const sessionsRaw = new SessionsRaw(mockDb); + + await sessionsRaw.aggregateSessionsAndPopulate({ + search: 'a|b', // pipe should not be escaped + }); + + expect(mockCollection.aggregate).toHaveBeenCalled(); + const pipeline = mockCollection.aggregate.mock.calls[0][0]; + const matchStage = pipeline.find((stage: any) => stage.$match); + + expect(matchStage.$match.$and[0]).toEqual({ searchTerm: { $regex: 'a|b', $options: 'i' } }); + }); +}); diff --git a/packages/models/src/models/Sessions.ts b/packages/models/src/models/Sessions.ts index eb481d806418f..3763bf38b457a 100644 --- a/packages/models/src/models/Sessions.ts +++ b/packages/models/src/models/Sessions.ts @@ -28,8 +28,6 @@ import type { FindOptions, } from 'mongodb'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; - import { getCollectionName } from '../index'; import { BaseRaw } from './BaseRaw'; import { readSecondaryPreferred } from '../readSecondaryPreferred'; @@ -757,7 +755,7 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { offset?: number; count?: number; }): Promise> { - const searchQuery = search ? [{ searchTerm: { $regex: escapeRegExp(search), $options: 'i' } }] : []; + const searchQuery = search ? [{ searchTerm: { $regex: search, $options: 'i' } }] : []; const matchOperator = { $match: { @@ -863,7 +861,7 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { offset?: number; count?: number; }): Promise> { - const searchQuery = search ? [{ searchTerm: { $regex: escapeRegExp(search), $options: 'i' } }] : []; + const searchQuery = search ? [{ searchTerm: { $regex: search, $options: 'i' } }] : []; const matchOperator = { $match: {