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 = '
${file.name}
${escapeHtml(file.name)}
+${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,