diff --git a/.changeset/tricky-fishes-hope.md b/.changeset/tricky-fishes-hope.md new file mode 100644 index 0000000000000..f1a9a5e5909a5 --- /dev/null +++ b/.changeset/tricky-fishes-hope.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/gazzodown': patch +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts index 32a3deb3973a2..a2fd69fc10afa 100644 --- a/apps/meteor/app/livechat/server/lib/sendTranscript.ts +++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts @@ -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'; @@ -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, @@ -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, @@ -75,7 +78,6 @@ export async function sendTranscript({ sort: { ts: 1 }, }, ); - let html = '

'; const InvalidFileMessage = `
${i18n.t( + ? DOMPurify.sanitize(` + ${i18n.t( messageType.message, messageType.data ? { ...messageType.data(message), interpolation: { escapeValue: false } } : { interpolation: { escapeValue: false } }, - )}` - : message.msg; + )}`) + : 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 += `
${attachment.title || ''}${InvalidFileMessage}
`; - continue; - } - - if (!attachment.image_type || !acceptableImageMimeTypes.includes(attachment.image_type)) { - filesHTML += `
${attachment.title || ''}${InvalidFileMessage}
`; + filesHTML += `
${escapeHtml(attachment.title || '')}${InvalidFileMessage}
`; continue; } - // Image attachment can be rendered in email body const file = message.files?.find((file) => file.name === attachment.title); if (!file) { - filesHTML += `
${attachment.title || ''}${InvalidFileMessage}
`; + filesHTML += `
${escapeHtml(attachment.title || '')}${InvalidFileMessage}
`; continue; } const uploadedFile = await Uploads.findOneById(file._id); if (!uploadedFile) { - filesHTML += `
${file.name}${InvalidFileMessage}
`; + filesHTML += `
${escapeHtml(file.name)}${InvalidFileMessage}
`; continue; } const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile); - filesHTML += `

${file.name}

`; + filesHTML += `
+

${escapeHtml(file.name)}

+ +
`; } } const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL'); const singleMessage = ` -

${author} ${datetime}

+

${escapeHtml(author)} ${escapeHtml(datetime)}

${messageContent}

${filesHTML}

`; diff --git a/packages/gazzodown/src/elements/LinkSpan.tsx b/packages/gazzodown/src/elements/LinkSpan.tsx index 6ac649768db6e..b1665ca95bc76 100644 --- a/packages/gazzodown/src/elements/LinkSpan.tsx +++ b/packages/gazzodown/src/elements/LinkSpan.tsx @@ -7,6 +7,7 @@ import BoldSpan from './BoldSpan'; import ItalicSpan from './ItalicSpan'; import PlainSpan from './PlainSpan'; import StrikeSpan from './StrikeSpan'; +import { sanitizeUrl } from './sanitizeUrl'; type LinkSpanProps = { href: string; @@ -14,6 +15,9 @@ type LinkSpanProps = { }; 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]; @@ -40,16 +44,16 @@ const LinkSpan = ({ href, label }: LinkSpanProps): ReactElement => { return labelElements; }, [label]); - if (isExternal(href)) { + if (isExternal(sanitizedHref)) { return ( - + {children} ); } return ( - + {children} ); diff --git a/packages/gazzodown/src/elements/sanitizeUrl.spec.ts b/packages/gazzodown/src/elements/sanitizeUrl.spec.ts new file mode 100644 index 0000000000000..b266b48da711f --- /dev/null +++ b/packages/gazzodown/src/elements/sanitizeUrl.spec.ts @@ -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¶m2=value2', + expected: 'https://example.com/path?param1=value1¶m2=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,', + 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==', + 'data:image/svg+xml,', + 'data:text/html,', + 'data:text/html;charset=utf-8,', + ]; + + 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('#'); + }); +}); diff --git a/packages/gazzodown/src/elements/sanitizeUrl.ts b/packages/gazzodown/src/elements/sanitizeUrl.ts new file mode 100644 index 0000000000000..49beaf4822fe9 --- /dev/null +++ b/packages/gazzodown/src/elements/sanitizeUrl.ts @@ -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 '#'; + } +};