diff --git a/packages/models/src/models/LivechatRooms.ts b/packages/models/src/models/LivechatRooms.ts index d388e7a39fd6e..eabcf3a50aa86 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,7 @@ 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('|')) }, + { 'email.thread': new RegExp(emailThread.map((t) => escapeRegExp(t)).join('|')) }, ], ...(departmentId && { departmentId }), }; @@ -1857,7 +1862,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); 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 196b5161aef5c..0c19ffdf89853 100644 --- a/packages/models/src/models/ModerationReports.ts +++ b/packages/models/src/models/ModerationReports.ts @@ -239,11 +239,11 @@ export class ModerationReportsRaw extends BaseRaw implements const fuzzyQuery = selector ? { - 'message.msg': { - $regex: selector, - $options: 'i', - }, - } + 'message.msg': { + $regex: selector, + $options: 'i', + }, + } : {}; const params = { 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' } }); + }); +});