Skip to content
17 changes: 13 additions & 4 deletions packages/models/src/models/LivechatRooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1330,8 +1330,9 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> 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'),
}));
}

Expand Down Expand Up @@ -1827,7 +1828,11 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
const query: Filter<IOmnichannelRoom> = {
'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);
Expand All @@ -1844,7 +1849,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> 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 }),
};
Expand All @@ -1857,7 +1862,11 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> 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);
Expand Down
66 changes: 66 additions & 0 deletions packages/models/src/models/ModerationReports.spec.ts
Original file line number Diff line number Diff line change
@@ -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('.*');
});
});
10 changes: 5 additions & 5 deletions packages/models/src/models/ModerationReports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,11 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements

const fuzzyQuery = selector
? {
'message.msg': {
$regex: selector,
$options: 'i',
},
}
'message.msg': {
$regex: selector,
$options: 'i',
},
}
: {};

const params = {
Expand Down
50 changes: 50 additions & 0 deletions packages/models/src/models/Sessions.spec.ts
Original file line number Diff line number Diff line change
@@ -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' } });
});
});