diff --git a/.changeset/fix-matrix-html-sanitization.md b/.changeset/fix-matrix-html-sanitization.md new file mode 100644 index 000000000..0cd57e65c --- /dev/null +++ b/.changeset/fix-matrix-html-sanitization.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix rich-text reply previews and custom-formatted messages so unsafe HTML is filtered more strictly and Matrix colors render correctly. diff --git a/package.json b/package.json index 396386ff2..3480ad70b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "classnames": "^2.5.1", "dayjs": "^1.11.19", "domhandler": "^5.0.3", + "dompurify": "^3.3.3", "emojibase": "^15.3.1", "emojibase-data": "^15.3.2", "eslint-plugin-react": "7.37.5", @@ -85,7 +86,6 @@ "react-i18next": "^16.5.4", "react-range": "^1.10.0", "react-router-dom": "^6.30.3", - "sanitize-html": "^2.17.1", "slate": "^0.123.0", "slate-dom": "^0.123.0", "slate-history": "^0.113.1", @@ -114,7 +114,6 @@ "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@types/react-google-recaptcha": "^2.1.9", - "@types/sanitize-html": "^2.16.0", "@types/ua-parser-js": "^0.7.39", "@vitejs/plugin-react": "^5.1.4", "@vitest/coverage-v8": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad8544f09..e5a3fb8a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: domhandler: specifier: ^5.0.3 version: 5.0.3 + dompurify: + specifier: ^3.3.3 + version: 3.3.3 emojibase: specifier: ^15.3.1 version: 15.3.1 @@ -180,9 +183,6 @@ importers: react-router-dom: specifier: ^6.30.3 version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - sanitize-html: - specifier: ^2.17.1 - version: 2.17.1 slate: specifier: ^0.123.0 version: 0.123.0 @@ -262,9 +262,6 @@ importers: '@types/react-google-recaptcha': specifier: ^2.1.9 version: 2.1.9 - '@types/sanitize-html': - specifier: ^2.16.0 - version: 2.16.1 '@types/ua-parser-js': specifier: ^0.7.39 version: 0.7.39 @@ -2836,9 +2833,6 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@types/sanitize-html@2.16.1': - resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} - '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3514,6 +3508,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4082,9 +4079,6 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - htmlparser2@8.0.2: - resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} - htmlparser2@9.0.0: resolution: {integrity: sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==} @@ -4689,9 +4683,6 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} - parse-srcset@1.0.2: - resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} - parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -4971,9 +4962,6 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - sanitize-html@2.17.1: - resolution: {integrity: sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==} - saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -8500,10 +8488,6 @@ snapshots: '@types/resolve@1.20.2': {} - '@types/sanitize-html@2.16.1': - dependencies: - htmlparser2: 10.1.0 - '@types/trusted-types@2.0.7': {} '@types/ua-parser-js@0.7.39': {} @@ -9247,6 +9231,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.3.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -10002,13 +9990,6 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 - htmlparser2@8.0.2: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 4.5.0 - htmlparser2@9.0.0: dependencies: domelementtype: 2.3.0 @@ -10644,8 +10625,6 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - parse-srcset@1.0.2: {} - parse5@8.0.0: dependencies: entities: 6.0.1 @@ -10979,15 +10958,6 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - sanitize-html@2.17.1: - dependencies: - deepmerge: 4.3.1 - escape-string-regexp: 4.0.0 - htmlparser2: 8.0.2 - is-plain-object: 5.0.0 - parse-srcset: 1.0.2 - postcss: 8.5.8 - saxes@6.0.0: dependencies: xmlchars: 2.2.0 diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 6f3617858..56517903b 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -86,6 +86,7 @@ function RenderMessageContentInternal({ [CaptionPosition.Inline]: 'row', [CaptionPosition.Hidden]: 'row', } satisfies Record; + const attachmentDirection = captionPositionMap[captionPosition]; const renderBody = useCallback( (props: any) => ( @@ -131,6 +132,7 @@ function RenderMessageContentInternal({ }, [ts, clientUrlPreview, urlPreview] ); + const messageUrlsPreview = urlPreview ? renderUrlsPreview : undefined; const renderCaption = () => { const hasCaption = content.body && content.body.trim().length > 0; @@ -143,7 +145,7 @@ function RenderMessageContentInternal({ edited={edited} content={content} renderBody={renderBody} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderUrlsPreview={messageUrlsPreview} /> ); return ( @@ -162,7 +164,7 @@ function RenderMessageContentInternal({ edited={edited} content={content} renderBody={renderBody} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderUrlsPreview={messageUrlsPreview} /> ); @@ -170,54 +172,53 @@ function RenderMessageContentInternal({ return null; }; - const renderFile = () => ( -
-
- ( - ( - } - /> - )} - renderAsTextFile={() => ( - } - /> - )} - > - +
{attachment}
+ {renderCaption()} +
+ ); + } + + const renderFile = () => + renderCaptionedAttachment( + ( + ( + } /> - - )} - outlined={outlineAttachment} - /> -
- {renderCaption()} - - ); + )} + renderAsTextFile={() => ( + } + /> + )} + > + + + )} + outlined={outlineAttachment} + /> + ); if (msgType === MsgType.Text) { return ( @@ -225,7 +226,7 @@ function RenderMessageContentInternal({ edited={edited} content={content} renderBody={renderBody} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderUrlsPreview={messageUrlsPreview} /> ); } @@ -246,7 +247,7 @@ function RenderMessageContentInternal({ edited={edited} content={content} renderBody={renderBody} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderUrlsPreview={messageUrlsPreview} /> ); } @@ -257,7 +258,7 @@ function RenderMessageContentInternal({ edited={edited} content={content} renderBody={renderBody} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + renderUrlsPreview={messageUrlsPreview} /> ); } @@ -270,101 +271,71 @@ function RenderMessageContentInternal({ content.body?.toLowerCase().endsWith('.webp') || (typeof content.url === 'string' && content.url.toLowerCase().includes('gif')); - return ( -
-
- ( - { - if (isGif && !autoplayGifs && p.src) { - return ( - - - - ); - } - return ; - }} - renderViewer={(p) => } - /> - )} - outlined={outlineAttachment} + return renderCaptionedAttachment( + ( + { + if (isGif && !autoplayGifs && p.src) { + return ( + + + + ); + } + return ; + }} + renderViewer={(p) => } /> -
- {renderCaption()} -
+ )} + outlined={outlineAttachment} + /> ); } if (msgType === MsgType.Video) { - return ( -
-
- ( - ( - ( - {body} - )} - /> - ) - : undefined - } - renderVideo={(p) => } - /> - )} - outlined={outlineAttachment} + return renderCaptionedAttachment( + ( + ( + ( + {body} + )} + /> + ) + : undefined + } + renderVideo={(p) => } /> -
- {renderCaption()} -
+ )} + outlined={outlineAttachment} + /> ); } if (msgType === MsgType.Audio) { - return ( -
-
- ( - } /> - )} - outlined={outlineAttachment} - /> -
- {renderCaption()} -
+ return renderCaptionedAttachment( + ( + } /> + )} + outlined={outlineAttachment} + /> ); } diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index e26b237b0..ad027db22 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -9,6 +9,25 @@ import { useRoomAbbreviationsContext } from '$hooks/useRoomAbbreviations'; import { splitByAbbreviations } from '$utils/abbreviations'; import { MessageEmptyContent } from './content'; +function getRenderedBodyText(text: string, highlightRegex?: RegExp): (string | JSX.Element)[] { + const emojiScaledText = scaleSystemEmoji(text); + + return highlightRegex ? highlightText(highlightRegex, emojiScaledText) : emojiScaledText; +} + +function renderLinkifiedBodyText( + text: string, + linkifyOpts: Opts, + highlightRegex: RegExp | undefined, + key?: string +): JSX.Element { + return ( + + {getRenderedBodyText(text, highlightRegex)} + + ); +} + type AbbreviationTermProps = { text: string; definition: string; @@ -92,7 +111,6 @@ export function buildAbbrReplaceTextNode( type RenderBodyProps = { body: string; customBody?: string; - highlightRegex?: RegExp; htmlReactParserOptions: HTMLReactParserOptions; linkifyOpts: Opts; @@ -107,7 +125,6 @@ export function RenderBody({ const abbrMap = useRoomAbbreviationsContext(); if (customBody) { - if (customBody === '') return ; return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions); } if (body === '') return ; @@ -122,24 +139,12 @@ export function RenderBody({ const definition = abbrMap.get(seg.termKey) ?? ''; return ; } - return ( - - {highlightRegex - ? highlightText(highlightRegex, scaleSystemEmoji(seg.text)) - : scaleSystemEmoji(seg.text)} - - ); + return renderLinkifiedBodyText(seg.text, linkifyOpts, highlightRegex, seg.id); })} ); } } - return ( - - {highlightRegex - ? highlightText(highlightRegex, scaleSystemEmoji(body)) - : scaleSystemEmoji(body)} - - ); + return renderLinkifiedBodyText(body, linkifyOpts, highlightRegex); } diff --git a/src/app/components/message/Reply.test.tsx b/src/app/components/message/Reply.test.tsx new file mode 100644 index 000000000..aa5c19fb5 --- /dev/null +++ b/src/app/components/message/Reply.test.tsx @@ -0,0 +1,129 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Reply } from './Reply'; + +const { mockUseRoomEvent, mockInvalidateQueries } = vi.hoisted(() => ({ + mockUseRoomEvent: vi.fn(), + mockInvalidateQueries: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + }), +})); + +vi.mock('jotai', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + useAtomValue: () => ({}), + }; +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('$hooks/useRoomEvent', () => ({ + useRoomEvent: (...args: unknown[]) => mockUseRoomEvent(...args), +})); + +vi.mock('$hooks/useSableCosmetics', () => ({ + useSableCosmetics: () => ({ color: undefined, font: undefined }), +})); + +vi.mock('$hooks/useMediaAuthentication', () => ({ + useMediaAuthentication: () => false, +})); + +vi.mock('$hooks/useIgnoredUsers', () => ({ + useIgnoredUsers: () => [], +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => + ({ + getRoom: () => undefined, + getUserId: () => '@me:example.com', + mxcUrlToHttp: () => null, + }) as any, +})); + +vi.mock('$hooks/useMemberEventParser', () => ({ + useMemberEventParser: () => vi.fn(), +})); + +vi.mock('$hooks/useMentionClickHandler', () => ({ + useMentionClickHandler: () => undefined, +})); + +vi.mock('$utils/room', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getMemberDisplayName: () => 'Alice', + }; +}); + +const createReplyEvent = (formattedBody: string) => + ({ + getContent: () => ({ + body: 'fallback body', + format: 'org.matrix.custom.html', + formatted_body: formattedBody, + msgtype: 'm.text', + }), + getSender: () => '@alice:example.com', + getType: () => 'm.room.message', + isRedacted: () => false, + isEncrypted: () => false, + isDecryptionFailure: () => false, + getClearContent: () => ({}), + }) as any; + +describe('Reply', () => { + it('sanitizes formatted_body before trimming and parsing the reply preview', () => { + mockUseRoomEvent.mockReturnValue( + createReplyEvent( + '
quoted
' + + 'visible' + + 'blocked image' + ) + ); + + render( + undefined } as any} + replyEventId="$reply:example.com" + /> + ); + + expect(screen.getByText('visible')).toBeInTheDocument(); + expect(screen.queryByText('quoted')).not.toBeInTheDocument(); + expect(screen.queryByText('alert(1)')).not.toBeInTheDocument(); + expect(screen.queryByAltText('blocked image')).not.toBeInTheDocument(); + }); + + it('does not render unresolved mxc images as raw browser img tags in reply previews', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + mockUseRoomEvent.mockReturnValue( + createReplyEvent( + 'blobcat' + ) + ); + + const { container } = render( + undefined } as any} + replyEventId="$reply:example.com" + /> + ); + + expect(container.querySelector('img[src^="mxc://"]')).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index a7e10729b..cf1786ca8 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -8,6 +8,7 @@ import { useAtomValue } from 'jotai'; import { getMemberDisplayName, trimReplyFromBody, trimReplyFromFormattedBody } from '$utils/room'; import { getMxIdLocalPart } from '$utils/matrix'; import { randomNumberBetween } from '$utils/common'; +import { sanitizeCustomHtml } from '$utils/sanitize'; import { getReactCustomHtmlParser, scaleSystemEmoji, @@ -25,7 +26,6 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMemberEventParser } from '$hooks/useMemberEventParser'; import { StateEvent, MessageEvent } from '$types/matrix/room'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; -import { sanitizeCustomHtml } from '$utils/sanitize'; import { useTranslation } from 'react-i18next'; import * as customHtmlCss from '$styles/CustomHtml.css'; import { @@ -93,7 +93,8 @@ type ReplyProps = { }; export const sanitizeReplyFormattedPreview = (formattedBody: string): string => { - const strippedHtml = trimReplyFromFormattedBody(formattedBody) + const safeFormattedBody = sanitizeCustomHtml(formattedBody); + const strippedHtml = trimReplyFromFormattedBody(safeFormattedBody) .replaceAll(//gi, ' ') .replaceAll(/<\/p>\s*]*>/gi, ' ') .replaceAll(/<\/?p[^>]*>/gi, '') @@ -101,7 +102,7 @@ export const sanitizeReplyFormattedPreview = (formattedBody: string): string => .replaceAll(/<\/?(ul|ol|li|blockquote|h[1-6]|pre|div)[^>]*>/gi, '') .replaceAll(/(?:\r\n|\r|\n)/g, ' '); - return sanitizeCustomHtml(strippedHtml); + return strippedHtml; }; export const Reply = as<'div', ReplyProps>( @@ -138,6 +139,9 @@ export const Reply = as<'div', ReplyProps>( const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; const mentionClickHandler = useMentionClickHandler(room.roomId); + const isFormattedReply = + format === 'org.matrix.custom.html' && typeof formattedBody === 'string'; + const hasPlainTextReply = typeof body === 'string' && body !== ''; // An encrypted event that hasn't been decrypted yet (keys pending) has an // empty result from getClearContent(). Treat it as still-loading rather @@ -170,7 +174,7 @@ export const Reply = as<'div', ReplyProps>( [mx, room.roomId, mentionClickHandler, nicknames] ); - if (format === 'org.matrix.custom.html' && formattedBody) { + if (isFormattedReply && formattedBody !== '') { const sanitizedHtml = sanitizeReplyFormattedPreview(formattedBody); const parserOpts = getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts: replyLinkifyOpts, @@ -179,7 +183,7 @@ export const Reply = as<'div', ReplyProps>( handleMentionClick: mentionClickHandler, }); bodyJSX = parse(sanitizedHtml, parserOpts) as JSX.Element; - } else if (body) { + } else if (hasPlainTextReply) { const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' '); bodyJSX = scaleSystemEmoji(strippedBody); } else if (eventType === StateEvent.RoomMember && !!replyEvent) { @@ -237,6 +241,13 @@ export const Reply = as<'div', ReplyProps>( ); } + let replyContent = bodyJSX; + if (isBlockedSender) { + replyContent = ; + } else if (badEncryption) { + replyContent = ; + } + return ( {threadRootId && ( @@ -261,11 +272,7 @@ export const Reply = as<'div', ReplyProps>( > {replyEvent !== undefined && !isPendingDecrypt ? ( - {(() => { - if (isBlockedSender) return ; - if (badEncryption) return ; - return bodyJSX; - })()} + {replyContent} ) : ( (isRedacted && ) || ( diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx index 7e29f3d49..133dc940e 100644 --- a/src/app/components/message/modals/MessageForward.tsx +++ b/src/app/components/message/modals/MessageForward.tsx @@ -23,7 +23,7 @@ import { allRoomsAtom } from '$state/room-list/roomList'; import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom'; import { factoryRoomIdByActivity } from '$utils/sort'; import * as css from '$features/room/message/styles.css'; -import { sanitizeCustomHtml } from '$utils/sanitize'; +import { sanitizeCustomHtml, sanitizeText } from '$utils/sanitize'; import { getStateEvents } from '$utils/room'; import { StateEvent } from '$types/matrix/room'; import { createDebugLogger } from '$utils/debugLogger'; @@ -206,12 +206,12 @@ export function MessageForwardInternal({ const safeHtml = originalFormattedBody === undefined - ? sanitizeCustomHtml(originalBody).replaceAll('\n', '
') + ? sanitizeText(originalBody).replaceAll('\n', '
') : sanitizeCustomHtml(originalFormattedBody); newBodyHtml = `
` + - `

${sanitizeCustomHtml(bodyModifText)}

` + + `

${sanitizeText(bodyModifText)}

` + `
${safeHtml}
` + `
`; } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 1f924a909..70eca4dc2 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -144,7 +144,7 @@ import { } from '$hooks/usePerMessageProfile'; import { Microphone, Stop } from '@phosphor-icons/react'; import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; -import { sanitizeCustomHtml } from '$utils/sanitize'; +import { sanitizeText } from '$utils/sanitize'; import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; import { SchedulePickerDialog } from './schedule-send'; @@ -812,7 +812,7 @@ export const RoomInput = forwardRef( /** * html escaped version of the display name */ - const escapedName = sanitizeCustomHtml(perMessageProfile.name); + const escapedName = sanitizeText(perMessageProfile.name); const htmlPrefix = `${escapedName}: `; diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 22c63ce26..ad25ba45f 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -72,7 +72,7 @@ import { HTMLReactParserOptions } from 'html-react-parser'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { GetContentCallback } from '$types/matrix/room'; -import { sanitizeCustomHtml } from '$utils/sanitize'; +import { sanitizeText } from '$utils/sanitize'; type MessageEditorProps = { roomId: string; @@ -204,7 +204,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( plainText = bodyPrefix + plainText; } - const escapedName = sanitizeCustomHtml(pmpDisplayname); + const escapedName = sanitizeText(pmpDisplayname); const htmlPrefix = `${escapedName}: `; if (!customHtml.startsWith(htmlPrefix)) { customHtml = htmlPrefix + customHtml; diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx new file mode 100644 index 000000000..a5015c1b2 --- /dev/null +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import parse from 'html-react-parser'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sanitizeCustomHtml } from '$utils/sanitize'; +import { getReactCustomHtmlParser, LINKIFY_OPTS } from './react-custom-html-parser'; + +function createMatrixClient() { + return { + getRoom: () => undefined, + getUserId: () => '@me:example.com', + mxcUrlToHttp: () => null, + } as any; +} + +function renderParsedHtml( + html: string, + options: { + sanitize?: boolean; + mx?: any; + } = {} +) { + const { sanitize = true, mx = createMatrixClient() } = options; + const parserOptions = getReactCustomHtmlParser(mx, '!room:example.com', { + linkifyOpts: LINKIFY_OPTS, + }); + + return render(
{parse(sanitize ? sanitizeCustomHtml(html) : html, parserOptions)}
); +} + +describe('getReactCustomHtmlParser', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('translates Matrix color data attributes into rendered styles', () => { + renderParsedHtml('colored'); + + expect(screen.getByText('colored')).toHaveStyle({ + color: 'rgb(255, 0, 0)', + backgroundColor: 'rgb(0, 255, 0)', + }); + }); + + it('drops incoming style attributes even if unsanitized html reaches the parser', () => { + renderParsedHtml( + 'styled', + { sanitize: false } + ); + + const styled = screen.getByText('styled'); + + expect(styled).toHaveStyle({ + color: 'rgb(255, 0, 0)', + }); + expect(styled).not.toHaveStyle({ + backgroundColor: 'rgb(0, 255, 0)', + }); + }); + + it('renders a readable fallback for unresolved legacy emote MXC images', () => { + const { container } = renderParsedHtml( + 'blobcat' + ); + + expect(screen.getByText(':blobcat:')).toBeInTheDocument(); + expect(container.querySelector('img[src^="mxc://"]')).toBeNull(); + }); + + it('renders a readable fallback for unresolved non-emote MXC images', () => { + const { container } = renderParsedHtml( + 'media' + ); + + expect(screen.getByText('media')).toBeInTheDocument(); + expect(container.querySelector('img[src^="mxc://"]')).toBeNull(); + }); + + it('renders unresolved MXC fallbacks without emitting debug logs', () => { + const logSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + renderParsedHtml('media'); + + expect(logSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 968850480..b1cf5e636 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -1,5 +1,6 @@ /* eslint-disable jsx-a11y/alt-text */ import { + CSSProperties, ComponentPropsWithoutRef, lazy, ReactEventHandler, @@ -35,6 +36,7 @@ import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '$utils/regex'; import { findAndReplace } from '$utils/findAndReplace'; import { onEnterOrSpace } from '$utils/keyboard'; import { copyToClipboard } from '$utils/dom'; +import { isMatrixHexColor } from '$utils/matrixHtml'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { @@ -68,6 +70,42 @@ export const safeDecodeUrl = (url: string) => { } }; +const getMatrixColorStyle = (attribs: Record): CSSProperties | undefined => { + const color = attribs['data-mx-color']; + const backgroundColor = attribs['data-mx-bg-color']; + + const style: CSSProperties = {}; + + if (typeof color === 'string' && isMatrixHexColor(color)) { + style.color = color; + } + + if (typeof backgroundColor === 'string' && isMatrixHexColor(backgroundColor)) { + style.backgroundColor = backgroundColor; + } + + return Object.keys(style).length > 0 ? style : undefined; +}; + +const stripIncomingStyle = ( + attribs: Record +): Omit, 'style'> => { + const { style, ...props } = attributesToProps(attribs); + + return props; +}; + +const ensureNoopenerRel = (rel: unknown): string => { + if (typeof rel !== 'string') return 'noopener'; + + const parts = rel.split(/\s+/).filter(Boolean); + if (!parts.includes('noopener')) { + parts.push('noopener'); + } + + return parts.join(' '); +}; + export const makeMentionCustomProps = ( handleMentionClick?: ReactEventHandler, content?: string @@ -363,7 +401,8 @@ export const getReactCustomHtmlParser = ( if (domNode instanceof Element && 'name' in domNode) { const { name, attribs, children, parent } = domNode; const renderChildren = () => domToReact(children as any, opts); - const props = attributesToProps(attribs); + const props = stripIncomingStyle(attribs); + const matrixColorStyle = getMatrixColorStyle(attribs); if (name === 'h1') { return ( @@ -494,8 +533,13 @@ export const getReactCustomHtmlParser = ( if (name === 'a' && typeof props.href === 'string') { const encodedHref = props.href; const decodedHref = encodedHref && safeDecodeUrl(encodedHref); + const anchorProps = { + ...props, + rel: ensureNoopenerRel(props.rel), + }; + if (!decodedHref || !testMatrixTo(decodedHref)) { - return undefined; + return {renderChildren()}; } const content = children.find((child) => !(child instanceof DOMText)) @@ -523,13 +567,21 @@ export const getReactCustomHtmlParser = ( onClick={params.handleSpoilerClick} className={css.Spoiler()} aria-pressed - style={{ cursor: 'pointer' }} + style={{ ...matrixColorStyle, cursor: 'pointer' }} > {renderChildren()} ); } + if (name === 'span' && matrixColorStyle) { + return ( + + {renderChildren()} + + ); + } + if (name === 'img') { // Guard: img without a src survives sanitisation (fix for crash #1731) // but we can't convert it — skip rendering rather than passing @@ -537,6 +589,8 @@ export const getReactCustomHtmlParser = ( if (!props.src) return null; const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication) ?? undefined; + const fallbackLabel = props.alt || props.title || '[media]'; + const failedToResolveMxc = props.src.startsWith('mxc://') && !htmlSrc; // Non-mxc images were already converted to links by the sanitiser, // but handle the edge case defensively here too. @@ -632,6 +686,14 @@ export const getReactCustomHtmlParser = ( ); } + if (failedToResolveMxc) { + return ( + + {fallbackLabel} + + ); + } + if (htmlSrc) return ( { - it('passes through permitted tags', () => { - expect(sanitizeCustomHtml('bold')).toBe('bold'); - expect(sanitizeCustomHtml('italic')).toBe('italic'); - expect(sanitizeCustomHtml('snippet')).toBe('snippet'); - }); +describe('sanitizeCustomHtml', () => { + it('keeps permitted Matrix v1.18 tags', () => { + const result = sanitizeCustomHtml( + '
Summary
text
' + ); - it('strips disallowed tags but keeps their text content', () => { - const result = sanitizeCustomHtml('text'); - expect(result).not.toContain(''); + expect(result).toContain('Summary'); + expect(result).toContain(''); + expect(result).toContain('text'); }); - it('strips and its content entirely', () => { - const result = sanitizeCustomHtml('quoted messageremaining'); - expect(result).not.toContain('quoted message'); - expect(result).toContain('remaining'); - }); -}); + it('strips tags outside the Matrix v1.18 allowlist', () => { + const result = sanitizeCustomHtml('fontstrike'); -describe('sanitizeCustomHtml – XSS prevention', () => { - it('strips "); - expect(result).not.toContain(' { - const result = sanitizeCustomHtml('click me'); - expect(result).not.toContain('onclick'); - expect(result).toContain('click me'); - }); + it('strips mx-reply and its contents entirely', () => { + const result = sanitizeCustomHtml('quoted message

remaining

'); - it('strips javascript: href on anchor tags', () => { - const result = sanitizeCustomHtml('link'); - expect(result).not.toMatch(/javascript:/); + expect(result).not.toContain('quoted message'); + expect(result).toBe('

remaining

'); }); - it('strips data: href on anchor tags', () => { + it('does not accept style attributes anywhere', () => { const result = sanitizeCustomHtml( - 'link' + 'text

para

' ); - expect(result).not.toContain('data:'); - }); - it('strips vbscript: href', () => { - const result = sanitizeCustomHtml('link'); - expect(result).not.toContain('vbscript:'); + expect(result).not.toContain('style='); + expect(result).toContain('data-mx-color="#ff0000"'); }); -}); -describe('sanitizeCustomHtml – link transformer', () => { - it('adds rel and target to http links', () => { - const result = sanitizeCustomHtml('link'); - expect(result).toContain('rel="noreferrer noopener"'); - expect(result).toContain('target="_blank"'); - }); + it('only keeps the permitted attributes on each tag', () => { + const result = sanitizeCustomHtml( + 'span' + + 'link' + + '
  1. item
' + + 'code' + + '
maths
' + ); - it('passes through existing href for http links', () => { - const result = sanitizeCustomHtml('link'); - expect(result).toContain('href="https://example.com"'); + expect(result).toContain('data-mx-color="#ff0000"'); + expect(result).toContain('data-mx-bg-color="#00ff00"'); + expect(result).toContain('data-mx-spoiler="spoiler"'); + expect(result).toContain('data-mx-maths="x"'); + expect(result).not.toContain('data-md='); + expect(result).toContain('link'); + expect(result).not.toContain('rel='); + expect(result).toContain('
    '); + expect(result).not.toContain('type='); + expect(result).not.toContain('class='); + expect(result).toContain('
    maths
    '); + }); + + it('preserves a code class only when every class starts with language-', () => { + expect(sanitizeCustomHtml('code')).toContain( + 'class="language-typescript"' + ); + expect( + sanitizeCustomHtml('code') + ).toContain('class="language-typescript language-js"'); + expect(sanitizeCustomHtml('code')).not.toContain( + 'class=' + ); }); -}); -describe('sanitizeCustomHtml – image transformer', () => { - it('keeps tags with mxc:// src', () => { - const result = sanitizeCustomHtml('img'); - expect(result).toContain(' { + expect(sanitizeCustomHtml('https')).toContain( + 'href="https://example.com"' + ); + expect(sanitizeCustomHtml('mail')).toContain( + 'href="mailto:test@example.com"' + ); + expect(sanitizeCustomHtml('magnet')).toContain( + 'href="magnet:?xt=urn:btih:abcdef"' + ); - it('converts with https:// src to a safe link', () => { - const result = sanitizeCustomHtml('photo'); - expect(result).not.toContain('relative')).toBe('relative'); + expect(sanitizeCustomHtml('matrix')).toBe( + 'matrix' + ); + expect(sanitizeCustomHtml('bad')).toBe('bad'); }); -}); -describe('sanitizeCustomHtml – style attribute restrictions', () => { - // The span transformer unconditionally overwrites the style attribute with - // values derived from data-mx-color / data-mx-bg-color. Inline CSS is always - // discarded; colors must come from the data-mx-* attributes. - it('converts data-mx-color to a CSS color style on span', () => { - const result = sanitizeCustomHtml('text'); - // sanitize-html may normalise whitespace around the colon - expect(result).toMatch(/color:\s*#ff0000/); - }); + it('keeps only mxc image sources', () => { + const allowed = sanitizeCustomHtml('img'); + const blocked = sanitizeCustomHtml('img'); - it('discards plain inline style on span (use data-mx-color instead)', () => { - const result = sanitizeCustomHtml('text'); - // The transformer replaces style with data-mx-* values; no data-mx-color - // present here, so style ends up stripped by the allowedStyles check. - expect(result).not.toContain('color: #ff0000'); + expect(allowed).toContain(' { - const result = sanitizeCustomHtml('text'); - expect(result).not.toContain('color: red'); + it('preserves legacy MSC2545 custom-emote markers on mxc images', () => { + const result = sanitizeCustomHtml( + 'blobcat' + ); + + expect(result).toContain('data-mx-emoticon'); + expect(result).toContain('src="mxc://example.com/abc123"'); + expect(result).toContain('alt="blobcat"'); }); - it('strips disallowed CSS properties', () => { - const result = sanitizeCustomHtml('text'); - expect(result).not.toContain('position'); + it('restores only one validated image src after masking duplicate image source attributes', () => { + const result = sanitizeCustomHtml( + 'img' + ); + + expect(result).toContain('src="mxc://example.com/primary"'); + expect(result).not.toContain('mxc://example.com/secondary'); + expect(result).not.toContain('srcset='); + expect(result.match(/\ssrc=/g)).toHaveLength(1); }); -}); -describe('sanitizeCustomHtml – code block class handling', () => { - it('preserves language class on code blocks', () => { - const result = sanitizeCustomHtml('const x = 1;'); - expect(result).toContain('class="language-typescript"'); + it('drops invalid Matrix color attributes instead of translating them to style', () => { + const result = sanitizeCustomHtml( + 'text' + ); + + expect(result).toBe('text'); }); - it('strips arbitrary classes not matching language-*', () => { - const result = sanitizeCustomHtml('code'); - expect(result).not.toContain('evil-class'); + it('enforces the 100-level nesting limit', () => { + const deepHtml = `${'
    '.repeat(101)}text${'
    '.repeat(101)}`; + const result = sanitizeCustomHtml(deepHtml); + + expect(result).toContain('text'); + expect((result.match(/
    /g) ?? []).length).toBeLessThanOrEqual(100); }); }); diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index e862fa9f3..28a1374e9 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -1,9 +1,10 @@ -import sanitizeHtml, { Transformer } from 'sanitize-html'; +import DOMPurify from 'dompurify'; +import { isMatrixHexColor } from './matrixHtml'; const MAX_TAG_NESTING = 100; +const INTERNAL_IMG_SRC_ATTR = 'data-sable-img-src'; const permittedHtmlTags = [ - 'font', 'del', 'h1', 'h2', @@ -24,7 +25,6 @@ const permittedHtmlTags = [ 'u', 'strong', 'em', - 'strike', 's', 'code', 'hr', @@ -42,123 +42,27 @@ const permittedHtmlTags = [ 'img', 'details', 'summary', -]; - -const urlSchemes = ['https', 'http', 'ftp', 'mailto', 'magnet']; +] as const; const permittedTagToAttributes = { - font: ['style', 'data-mx-bg-color', 'data-mx-color', 'color'], - span: [ - 'style', - 'data-mx-bg-color', - 'data-mx-color', - 'data-mx-spoiler', - 'data-mx-maths', - 'data-mx-pill', - 'data-mx-ping', - 'data-md', - ], + span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-maths'], + a: ['target', 'href'], + img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'], // data-mx-emoticon is for MSC2545 + ol: ['start'], + code: ['class'], div: ['data-mx-maths'], - blockquote: ['data-md'], - h1: ['data-md'], - h2: ['data-md'], - h3: ['data-md'], - h4: ['data-md'], - h5: ['data-md'], - h6: ['data-md'], - pre: ['data-md', 'class'], - ol: ['start', 'type', 'data-md'], - ul: ['data-md'], - a: ['name', 'target', 'href', 'rel', 'data-md'], - img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'], - code: ['class', 'data-md'], - strong: ['data-md'], - i: ['data-md'], - em: ['data-md'], - u: ['data-md'], - s: ['data-md'], - del: ['data-md'], - sub: ['data-md'], - hr: ['data-md'], -}; +} as const satisfies Record; -const transformFontTag: Transformer = (tagName, attribs) => ({ - tagName, - attribs: { - ...attribs, - style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`, - }, -}); - -const transformSpanTag: Transformer = (tagName, attribs) => ({ - tagName, - attribs: { - ...attribs, - style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`, - }, -}); - -const transformATag: Transformer = (tagName, attribs) => ({ - tagName, - attribs: { - ...attribs, - rel: 'noreferrer noopener', - target: '_blank', - }, -}); - -const transformImgTag: Transformer = (tagName, attribs) => { - const { src } = attribs; - if (typeof src === 'string' && src.startsWith('mxc://') === false) { - return { - tagName: 'a', - attribs: { - href: src, - rel: 'noreferrer noopener', - target: '_blank', - }, - text: attribs.alt || src, - }; - } - return { - tagName, - attribs: { - ...attribs, - }, - }; -}; +const permittedHtmlAttributes = Array.from(new Set(Object.values(permittedTagToAttributes).flat())); -export const sanitizeCustomHtml = (customHtml: string): string => - sanitizeHtml(customHtml, { - allowedTags: permittedHtmlTags, - allowedAttributes: permittedTagToAttributes, - disallowedTagsMode: 'discard', - allowedSchemes: urlSchemes, - allowedSchemesByTag: { - a: urlSchemes, - }, - allowedSchemesAppliedToAttributes: ['href'], - allowProtocolRelative: false, - allowedClasses: { - code: ['language-*'], - }, - allowedStyles: { - '*': { - color: [/^#(?:[0-9a-fA-F]{3}){1,2}$/], - 'background-color': [/^#(?:[0-9a-fA-F]{3}){1,2}$/], - }, - }, - transformTags: { - font: transformFontTag, - span: transformSpanTag, - a: transformATag, - img: transformImgTag, - }, - nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript', 'mx-reply'], - nestingLimit: MAX_TAG_NESTING, - }); +const allowedLinkSchemes = new Set(['https', 'http', 'ftp', 'mailto', 'magnet']); +const forbiddenContentTags = ['mx-reply', 'script', 'style', 'textarea', 'option', 'noscript']; + +const codeLanguageClassRegex = /^language-[A-Za-z0-9_-]+$/; +const orderedListStartRegex = /^-?\d+$/; +const allowedUriRegex = /^(?:https?|ftp|mailto|magnet|mxc):/i; -export const sanitizeText = (body: string) => { +export function sanitizeText(body: string): string { const tagsToReplace: Record = { '&': '&', '<': '<', @@ -166,5 +70,259 @@ export const sanitizeText = (body: string) => { '"': '"', "'": ''', }; + return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag); +} + +function tagAllowsAttribute(tagName: string, attrName: string): boolean { + const allowedAttributes = + permittedTagToAttributes[tagName as keyof typeof permittedTagToAttributes]; + + return allowedAttributes ? (allowedAttributes as readonly string[]).includes(attrName) : false; +} + +function isAllowedAbsoluteLink(value: string): boolean { + try { + const parsed = new URL(value); + return allowedLinkSchemes.has(parsed.protocol.slice(0, -1)); + } catch { + return false; + } +} + +function isAllowedMxcUri(value: string): boolean { + try { + const parsed = new URL(value); + return ( + parsed.protocol === 'mxc:' && + parsed.host.length > 0 && + parsed.pathname.length > 1 && + parsed.username === '' && + parsed.password === '' && + parsed.search === '' && + parsed.hash === '' + ); + } catch { + return false; + } +} + +function normalizeCodeClasses(attrValue: string): string | undefined { + const classes = attrValue.split(/\s+/).filter(Boolean); + if ( + classes.length === 0 || + classes.some((className) => !codeLanguageClassRegex.test(className)) + ) { + return undefined; + } + + return classes.join(' '); +} + +function getValidatedAttributeValue( + tagName: string, + attrName: string, + attrValue: string +): string | undefined { + if (attrName === 'style') { + return undefined; + } + + if ( + (attrName === 'data-mx-color' || attrName === 'data-mx-bg-color') && + !isMatrixHexColor(attrValue) + ) { + return undefined; + } + + if (tagName === 'a' && attrName === 'href' && !isAllowedAbsoluteLink(attrValue)) { + return undefined; + } + + if (tagName === 'img' && attrName === 'src' && !isAllowedMxcUri(attrValue)) { + return undefined; + } + + if (tagName === 'ol' && attrName === 'start' && !orderedListStartRegex.test(attrValue)) { + return undefined; + } + + if (tagName === 'code' && attrName === 'class') { + return normalizeCodeClasses(attrValue); + } + + return attrValue; +} + +function getValidatedProtectedImageSource( + sourceId: string | null, + protectedSources?: Map +): string | undefined { + const rawSrc = sourceId ? protectedSources?.get(sourceId) : undefined; + + return typeof rawSrc === 'string' ? getValidatedAttributeValue('img', 'src', rawSrc) : undefined; +} + +function protectImageSources(customHtml: string): { + protectedHtml: string; + protectedSources: Map; +} { + const protectedSources = new Map(); + const protectedHtml = customHtml.replace(/]*>/gi, (imgTag) => { + let protectedSourceId: string | undefined; + + const strippedTag = imgTag.replace( + /\s(src|srcset)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi, + (_match, attrName: string, _value, doubleQuoted, singleQuoted, bareValue) => { + if (attrName.toLowerCase() !== 'src') { + return ''; + } + + if (protectedSourceId !== undefined) { + return ''; + } + + const rawSrc = doubleQuoted ?? singleQuoted ?? bareValue ?? ''; + protectedSourceId = `${protectedSources.size}`; + protectedSources.set(protectedSourceId, rawSrc); + + return ` ${INTERNAL_IMG_SRC_ATTR}="${protectedSourceId}"`; + } + ); + + return strippedTag; + }); + + return { + protectedHtml, + protectedSources, + }; +} + +function restoreProtectedImageSources( + sanitizedHtml: string, + protectedSources: Map +): string { + return sanitizedHtml.replace( + new RegExp(`\\s${INTERNAL_IMG_SRC_ATTR}="([^"]+)"`, 'g'), + (_match, sourceId: string) => { + const validatedSrc = getValidatedProtectedImageSource(sourceId, protectedSources); + if (!validatedSrc) return ''; + + return ` src="${sanitizeText(validatedSrc)}"`; + } + ); +} + +const enforceNestingLimit = (fragment: DocumentFragment): void => { + const overlyNestedElements: Array<{ depth: number; element: Element }> = []; + + const collect = (node: ParentNode, depth: number) => { + Array.from(node.childNodes).forEach((child) => { + if (!(child instanceof Element)) return; + + const childDepth = depth + 1; + if (childDepth > MAX_TAG_NESTING) { + overlyNestedElements.push({ depth: childDepth, element: child }); + } + + collect(child, childDepth); + }); + }; + + collect(fragment, 0); + + overlyNestedElements + .sort((a, b) => b.depth - a.depth) + .forEach(({ element }) => { + if (!element.parentNode) return; + element.replaceWith(...Array.from(element.childNodes)); + }); +}; + +const pruneInvalidEmptyElements = ( + fragment: DocumentFragment, + protectedSources?: Map +): void => { + fragment.querySelectorAll('img').forEach((img) => { + const validatedProtectedSource = getValidatedProtectedImageSource( + img.getAttribute(INTERNAL_IMG_SRC_ATTR), + protectedSources + ); + + if (!img.getAttribute('src') && !validatedProtectedSource) { + img.remove(); + } + }); +}; + +export const sanitizeCustomHtml = (customHtml: string): string => { + if (typeof window === 'undefined') { + return sanitizeText(customHtml); + } + + const { protectedHtml, protectedSources } = protectImageSources(customHtml); + const purify = DOMPurify(window); + const allowedHtmlAttributes = [...permittedHtmlAttributes, INTERNAL_IMG_SRC_ATTR]; + + purify.addHook('uponSanitizeAttribute', (currentNode, hookEvent) => { + const tagName = currentNode.tagName.toLowerCase(); + const attrName = hookEvent.attrName.toLowerCase(); + + if (tagName === 'img' && attrName === INTERNAL_IMG_SRC_ATTR) { + if (!protectedSources.has(hookEvent.attrValue)) { + // eslint-disable-next-line no-param-reassign + hookEvent.keepAttr = false; + return; + } + + // eslint-disable-next-line no-param-reassign + hookEvent.forceKeepAttr = true; + return; + } + + if (!tagAllowsAttribute(tagName, attrName)) { + // DOMPurify exposes attribute decisions by mutating the hook event. + // eslint-disable-next-line no-param-reassign + hookEvent.keepAttr = false; + return; + } + + const validatedAttrValue = getValidatedAttributeValue(tagName, attrName, hookEvent.attrValue); + if (validatedAttrValue === undefined) { + // eslint-disable-next-line no-param-reassign + hookEvent.keepAttr = false; + return; + } + + // eslint-disable-next-line no-param-reassign + hookEvent.attrValue = validatedAttrValue; + // eslint-disable-next-line no-param-reassign + hookEvent.forceKeepAttr = true; + }); + + const sanitizedNode = purify.sanitize(protectedHtml, { + ALLOWED_TAGS: [...permittedHtmlTags], + ALLOWED_ATTR: allowedHtmlAttributes, + ALLOW_ARIA_ATTR: false, + ALLOW_DATA_ATTR: true, + FORBID_ATTR: ['style'], + FORBID_TAGS: ['mx-reply'], + FORBID_CONTENTS: forbiddenContentTags, + KEEP_CONTENT: true, + ALLOWED_URI_REGEXP: allowedUriRegex, + RETURN_DOM_FRAGMENT: true, + }); + + if (!(sanitizedNode instanceof window.DocumentFragment)) { + return sanitizeText(customHtml); + } + + const fragment = sanitizedNode; + enforceNestingLimit(fragment); + pruneInvalidEmptyElements(fragment, protectedSources); + + const container = document.createElement('div'); + container.append(fragment); + return restoreProtectedImageSources(container.innerHTML, protectedSources); };