Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tricky-fishes-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/gazzodown': patch
'@rocket.chat/meteor': patch
---

Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
49 changes: 28 additions & 21 deletions apps/meteor/app/livechat/server/lib/sendTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
import colors from '@rocket.chat/fuselage-tokens/colors';
import { Logger } from '@rocket.chat/logger';
import { LivechatRooms, Messages, Uploads, Users } from '@rocket.chat/models';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import moment from 'moment-timezone';

import { callbacks } from '../../../../lib/callbacks';
Expand All @@ -24,6 +26,8 @@ import { getTimezone } from '../../../utils/server/lib/getTimezone';

const logger = new Logger('Livechat-SendTranscript');

const DOMPurify = createDOMPurify(new JSDOM('').window);

export async function sendTranscript({
token,
rid,
Expand Down Expand Up @@ -65,7 +69,6 @@ export async function sendTranscript({
'livechat_video_call',
'omnichannel_priority_change_history',
];
const acceptableImageMimeTypes = ['image/jpeg', 'image/png', 'image/jpg'];
const messages = Messages.findVisibleByRoomIdNotContainingTypesBeforeTs(
rid,
ignoredMessageTypes,
Expand All @@ -75,7 +78,6 @@ export async function sendTranscript({
sort: { ts: 1 },
},
);

let html = '<div> <hr>';
const InvalidFileMessage = `<div style="background-color: ${colors.n100}; text-align: center; border-color: ${
colors.n250
Expand All @@ -84,6 +86,17 @@ export async function sendTranscript({
{ lng: userLanguage },
)}</div>`;

function escapeHtml(str: string): string {
if (typeof str !== 'string') return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}

for await (const message of messages) {
let author;
if (message.u._id === visitor._id) {
Expand All @@ -96,62 +109,56 @@ export async function sendTranscript({
const messageType = isSystemMessage && MessageTypes.getType(message);

let messageContent = messageType
? `<i>${i18n.t(
? DOMPurify.sanitize(`
<i>${i18n.t(
messageType.message,
messageType.data
? { ...messageType.data(message), interpolation: { escapeValue: false } }
: { interpolation: { escapeValue: false } },
)}</i>`
: message.msg;
)}</i>`)
: escapeHtml(message.msg);

let filesHTML = '';

if (message.attachments && message.attachments?.length > 0) {
messageContent = message.attachments[0].description || '';
escapeHtml(messageContent);

for await (const attachment of message.attachments) {
if (!isFileAttachment(attachment)) {
// ignore other types of attachments
continue;
}

if (!isFileImageAttachment(attachment)) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
continue;
}

if (!attachment.image_type || !acceptableImageMimeTypes.includes(attachment.image_type)) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
filesHTML += `<div>${escapeHtml(attachment.title || '')}${InvalidFileMessage}</div>`;
continue;
}

// Image attachment can be rendered in email body
const file = message.files?.find((file) => file.name === attachment.title);

if (!file) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
filesHTML += `<div>${escapeHtml(attachment.title || '')}${InvalidFileMessage}</div>`;
continue;
}

const uploadedFile = await Uploads.findOneById(file._id);

if (!uploadedFile) {
filesHTML += `<div>${file.name}${InvalidFileMessage}</div>`;
filesHTML += `<div>${escapeHtml(file.name)}${InvalidFileMessage}</div>`;
continue;
}

const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile);
filesHTML += `<div styles="color: ${colors.n700}; margin-top: 4px; flex-direction: "column";"><p>${file.name}</p><img src="data:${
attachment.image_type
};base64,${uploadedFileBuffer.toString(
'base64',
)}" style="width: 400px; max-height: 240px; object-fit: contain; object-position: 0;"/></div>`;
filesHTML += `<div style="color: ${colors.n700}; margin-top: 4px; flex-direction: column;">
<p>${escapeHtml(file.name)}</p>
<img src="data:${attachment.image_type};base64,${uploadedFileBuffer.toString('base64')}" style="width: 400px; max-height: 240px; object-fit: contain; object-position: 0;" />
</div>`;
}
}

const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL');
const singleMessage = `
<p><strong>${author}</strong> <em>${datetime}</em></p>
<p><strong>${escapeHtml(author)}</strong> <em>${escapeHtml(datetime)}</em></p>
<p>${messageContent}</p>
<p>${filesHTML}</p>
`;
Expand Down
10 changes: 7 additions & 3 deletions packages/gazzodown/src/elements/LinkSpan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import BoldSpan from './BoldSpan';
import ItalicSpan from './ItalicSpan';
import PlainSpan from './PlainSpan';
import StrikeSpan from './StrikeSpan';
import { sanitizeUrl } from './sanitizeUrl';

type LinkSpanProps = {
href: string;
label: MessageParser.Markup | MessageParser.Markup[];
};

const LinkSpan = ({ href, label }: LinkSpanProps): ReactElement => {
// Should sanitize 'href' if any of the insecure prefixes are present - see DSK-34 on Jira
const sanitizedHref = sanitizeUrl(href);

const { t } = useTranslation();
const children = useMemo(() => {
const labelArray = Array.isArray(label) ? label : [label];
Expand All @@ -40,16 +44,16 @@ const LinkSpan = ({ href, label }: LinkSpanProps): ReactElement => {
return labelElements;
}, [label]);

if (isExternal(href)) {
if (isExternal(sanitizedHref)) {
return (
<a href={href} title={href} rel='noopener noreferrer' target='_blank'>
<a href={sanitizedHref} title={sanitizedHref} rel='noopener noreferrer' target='_blank'>
{children}
</a>
);
}

return (
<a href={href} title={t('Go_to_href', { href: href.replace(getBaseURI(), '') })}>
<a href={sanitizedHref} title={t('Go_to_href', { href: sanitizedHref.replace(getBaseURI(), '') })}>
{children}
</a>
);
Expand Down
113 changes: 113 additions & 0 deletions packages/gazzodown/src/elements/sanitizeUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { sanitizeUrl } from './sanitizeUrl';

describe('sanitizeUrl', () => {
it('allows safe HTTP URLs', () => {
expect(sanitizeUrl('http://example.com')).toBe('http://example.com/');
});

it('allows safe HTTPS URLs', () => {
expect(sanitizeUrl('https://secure.com')).toBe('https://secure.com/');
});

it('allows safe URLs with query parameters', () => {
const testCases = [
{
input: 'https://example.com/?q=test',
expected: 'https://example.com/?q=test',
},
{
input: 'http://example.com/search?query=hello+world',
expected: 'http://example.com/search?query=hello+world',
},
{
input: 'https://example.com/path?param1=value1&param2=value2',
expected: 'https://example.com/path?param1=value1&param2=value2',
},
{
input: 'https://example.com/?redirect=http://safe.com',
expected: 'https://example.com/?redirect=http://safe.com',
},

{
input: 'https://example.com/?xss=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
expected: 'https://example.com/?xss=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
},
{
input: 'https://example.com/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
expected: 'https://example.com/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E',
},
{
input: 'https://example.com/?param1=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E',
expected: 'https://example.com/?param1=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E',
},
{
input: 'https://example.com/?q=%3Cimg+src%3D%22javascript%3Aalert(1)%22%3E',
expected: 'https://example.com/?q=%3Cimg+src%3D%22javascript%3Aalert(1)%22%3E',
},
];

testCases.forEach(({ input, expected }) => {
expect(sanitizeUrl(input)).toBe(expected);
});
});

describe('sanitizeUrl - XSS Payloads', () => {
it('sanitizes javascript: URLs', () => {
const payloads = [
'javascript:alert(1)',
'javascript:confirm("XSS")',
'javascript:/*XSS*/alert(1)',
'javascript:eval("alert(1)")',
'javascript:window.location="http://evil.com"',
];

payloads.forEach((payload) => {
expect(sanitizeUrl(payload)).toBe('#');
});
});

it('sanitizes data: URLs', () => {
const payloads = [
'data:text/html,<script>alert("XSS")</script>',
'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
'data:image/svg+xml,<svg onload=alert(1)>',
'data:text/html,<iframe src="javascript:alert(1)"></iframe>',
'data:text/html;charset=utf-8,<script>confirm("XSS")</script>',
];

payloads.forEach((payload) => {
expect(sanitizeUrl(payload)).toBe('#');
});
});

it('sanitizes vbscript: URLs', () => {
const payloads = [
'vbscript:msgbox("XSS")',
'vbscript:alert("Hello")',
'vbscript:Execute("msgbox(123)")',
'VBScript:CreateObject("WScript.Shell").Run("calc")',
'VBSCRIPT:MsgBox("payload")',
];

payloads.forEach((payload) => {
expect(sanitizeUrl(payload)).toBe('#');
});
});
});

it('sanitizes malformed URLs', () => {
expect(sanitizeUrl('ht^tp://broken')).toBe('#');
});

it('sanitizes empty string', () => {
expect(sanitizeUrl('')).toBe('#');
});

it('is case-insensitive with protocols', () => {
expect(sanitizeUrl('JAVASCRIPT:alert(1)')).toBe('#');
});

it('sanitizes nonsense input', () => {
expect(sanitizeUrl('💣💥🤯')).toBe('#');
});
});
9 changes: 9 additions & 0 deletions packages/gazzodown/src/elements/sanitizeUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const sanitizeUrl = (href: string) => {
try {
const url = new URL(href);
const dangerousProtocols = ['javascript:', 'data:', 'vbscript:'];
return dangerousProtocols.includes(url.protocol.toLowerCase()) ? '#' : url.href;
} catch {
return '#';
}
};
Loading