diff --git a/.changeset/fast-apes-know.md b/.changeset/fast-apes-know.md new file mode 100644 index 0000000000000..47242a936a4c2 --- /dev/null +++ b/.changeset/fast-apes-know.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Escapes HTML tags in exported data diff --git a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts index 5b46c07d2e179..7649c32a5694a 100644 --- a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts +++ b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from 'fs/promises'; import type { IMessage, IRoom, IUser, MessageAttachment, FileProp, RoomType, IExportOperation } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import { settings } from '../../../app/settings/server'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; @@ -152,7 +153,7 @@ export const getMessageData = ( return messageObject; }; -export const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFile?: FileProp): string => { +export const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFiles: FileProp[] = []): string => { if (type === 'json') { return JSON.stringify(messageObject); } @@ -164,19 +165,22 @@ export const exportMessageObject = (type: 'json' | 'html', messageObject: Messag const italicTypes: IMessage['t'][] = ['uj', 'ul', 'au', 'r', 'ru', 'wm', 'livechat-close']; - const message = italicTypes.includes(messageType) ? `${messageObject.msg}` : messageObject.msg; + const safeMsg = escapeHTML(messageObject.msg ?? ''); + const message = italicTypes.includes(messageType) ? `${safeMsg}` : safeMsg; - file.push(`

${messageObject.username} (${timestamp}):
`); + file.push(`

${escapeHTML(messageObject.username ?? '')} (${timestamp}):
`); file.push(message); - if (messageFile?._id) { - const attachment = messageObject.attachments?.find((att) => att.type === 'file' && att.title_link?.includes(messageFile._id)); + for (const messageFile of messageFiles) { + if (messageFile?._id) { + const attachment = messageObject.attachments?.find((att) => att.type === 'file' && att.title_link?.includes(messageFile._id)); - const description = attachment?.title || i18n.t('Message_Attachments'); + const description = attachment?.title || i18n.t('Message_Attachments'); - const assetUrl = `./assets/${messageFile._id}-${messageFile.name}`; - const link = `
${description}`; - file.push(link); + const assetUrl = `./assets/${messageFile._id}-${messageFile.name}`; + const link = `
${escapeHTML(description)}`; + file.push(link); + } } file.push('

'); @@ -218,11 +222,11 @@ export const exportRoomMessages = async ( results.forEach((msg) => { const messageObject = getMessageData(msg, hideUsers, userData, usersMap); - if (msg.file) { - result.uploads.push(msg.file); - } + // handle both new format (msg.files array) and old format (msg.file) for backward compatibility + const files = (msg.files || (msg.file ? [msg.file] : [])).filter(Boolean) as FileProp[]; - result.messages.push(exportMessageObject(exportType, messageObject, msg.file)); + result.uploads.push(...files); + result.messages.push(exportMessageObject(exportType, messageObject, files)); }); return result; @@ -267,7 +271,14 @@ export const exportRoomMessagesToFile = async function ( if (exportOpRoomData.status === 'pending') { exportOpRoomData.status = 'exporting'; if (exportType === 'html') { - await writeFile(filePath, '', { encoding: 'utf8' }); + await writeFile( + filePath, + [ + '', + ``, + ].join('\n'), + { encoding: 'utf8' }, + ); } } diff --git a/apps/meteor/server/lib/dataExport/processDataDownloads.ts b/apps/meteor/server/lib/dataExport/processDataDownloads.ts index 5a41c7d28713e..b07ba97a3a6d4 100644 --- a/apps/meteor/server/lib/dataExport/processDataDownloads.ts +++ b/apps/meteor/server/lib/dataExport/processDataDownloads.ts @@ -3,6 +3,7 @@ import { access, mkdir, rm, writeFile } from 'fs/promises'; import type { IExportOperation, IUser, RoomType } from '@rocket.chat/core-typings'; import { Avatars, ExportOperations, UserDataFiles, Subscriptions } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import moment from 'moment'; import { v4 as uuidv4 } from 'uuid'; @@ -79,15 +80,16 @@ const generateUserFile = async (exportOperation: IExportOperation, userData?: IU stream.write('\n'); stream.write('\n'); + stream.write(`\n`); for (const [key, value] of Object.entries(dataToSave)) { - stream.write(`

${key}:`); + stream.write(`

${escapeHTML(key)}:`); if (typeof value === 'string') { - stream.write(value); + stream.write(escapeHTML(value)); } else if (Array.isArray(value)) { stream.write('
'); for (const item of value) { - stream.write(`${item}
`); + stream.write(`${escapeHTML(String(item))}
`); } } diff --git a/apps/meteor/server/lib/dataExport/sendViaEmail.ts b/apps/meteor/server/lib/dataExport/sendViaEmail.ts index dda337c851e44..834128a6a7f2a 100644 --- a/apps/meteor/server/lib/dataExport/sendViaEmail.ts +++ b/apps/meteor/server/lib/dataExport/sendViaEmail.ts @@ -1,5 +1,6 @@ import type { IMessage, IUser } from '@rocket.chat/core-typings'; import { Messages, Users } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import moment from 'moment'; import * as Mailer from '../../../app/mailer/server/api'; @@ -67,9 +68,9 @@ export async function sendViaEmail( ) .map((message: IMessage) => { const dateTime = moment(message.ts).locale(lang).format('L LT'); - return `

${ - message.u.username - } ${dateTime}
${Message.parse(message, data.language)}

`; + return `

${escapeHTML( + message.u.username ?? '', + )} ${dateTime}
${Message.parse(message, data.language)}

`; }) .join(''); diff --git a/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts index 6fb15a6802823..511390944a28e 100644 --- a/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts +++ b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts @@ -87,7 +87,7 @@ describe('Export - exportMessageObject', () => { }); it('should correctly reference file when exporting a message object with an attachment as html', async () => { - const result = await exportMessageObject('html', messagesData[1], exportMessagesMock[1].file); + const result = await exportMessageObject('html', messagesData[1], [exportMessagesMock[1].file]); expect(result).to.be.a.string; expect(result).to.equal( @@ -100,7 +100,7 @@ describe('Export - exportMessageObject', () => { }); it('should use fallback attachment description when no title is provided on message object export as html', async () => { - const result = await exportMessageObject('html', messagesData[2], exportMessagesMock[2].file); + const result = await exportMessageObject('html', messagesData[2], [exportMessagesMock[2].file]); expect(stubs.translateKey.calledWith('Message_Attachments')).to.be.true; expect(result).to.be.a.string; @@ -114,6 +114,75 @@ describe('Export - exportMessageObject', () => { }); }); +describe('Export - exportMessageObject HTML escaping (XSS prevention)', () => { + const ts = new Date('2020-01-01T00:00:00.000Z'); + + it('should escape HTML in the message body when exporting as html', async () => { + const result = await exportMessageObject('html', { + msg: '', + username: 'attacker', + ts, + }); + + expect(result).to.contain('<img src=x onerror="alert(1)">'); + expect(result).to.not.contain(' { + const result = await exportMessageObject('html', { + msg: 'hello', + username: '', + ts, + }); + + expect(result).to.contain('<script>alert(1)</script>'); + expect(result).to.not.contain('