diff --git a/packages/component/package.json b/packages/component/package.json index 04fc07af05..271b6d7165 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -101,6 +101,7 @@ "classnames": "2.3.2", "compute-scroll-into-view": "1.0.20", "deep-freeze-strict": "^1.1.1", + "dompurify": "^3.1.2", "event-target-shim": "6.0.2", "markdown-it": "13.0.2", "math-random": "2.0.1", @@ -127,4 +128,4 @@ "botframework-webchat-api": "0.0.0-0", "botframework-webchat-core": "0.0.0-0" } -} +} \ No newline at end of file diff --git a/packages/component/src/Attachment/Text/private/CitationModalContent.tsx b/packages/component/src/Attachment/Text/private/CitationModalContent.tsx index 85cffcca54..ea44a30401 100644 --- a/packages/component/src/Attachment/Text/private/CitationModalContent.tsx +++ b/packages/component/src/Attachment/Text/private/CitationModalContent.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; -import React, { Fragment, memo } from 'react'; +import React, { Fragment, useCallback } from 'react'; +import { sanitize } from 'dompurify'; import useRenderMarkdownAsHTML from '../../../hooks/useRenderMarkdownAsHTML'; import useStyleSet from '../../../hooks/useStyleSet'; @@ -9,9 +10,32 @@ type Props = Readonly<{ markdown: string; }>; -const CitationModalContent = memo(({ headerText, markdown }: Props) => { +const CitationModalContent = ({ headerText, markdown }: Props) => { const [{ renderMarkdown: renderMarkdownStyleSet }] = useStyleSet(); const renderMarkdownAsHTML = useRenderMarkdownAsHTML(); + const domParser = new DOMParser(); + + // return the DOM tree if parsing this string returns anything with a non-text HTML node in it, otherwise + // parse it as Markdown into HTML and return that tree. Sanitizes the output in either case. + function parseIntoHTML(text: string): HTMLElement { + // DOMParser is safe; even if it finds potentially dangerous objects, it doesn't run them, just parses them. + const parsedBody = domParser.parseFromString(text, 'text/html').body; + // need to use the old-school syntax here for ES version reasons + for (let i = 0; i < parsedBody.childNodes.length; i++) { + const node = parsedBody.childNodes[i]; + if (node.nodeType !== Node.TEXT_NODE) { + return sanitize(parsedBody, { RETURN_DOM: true }); + } + } + return sanitize(renderMarkdownAsHTML(text), { RETURN_DOM: true }); + } + const contents = parseIntoHTML(markdown); + const renderChildren = useCallback( + ref => { + contents.childNodes.forEach(node => ref?.appendChild(node)); + }, + [contents] + ); return ( @@ -23,16 +47,14 @@ const CitationModalContent = memo(({ headerText, markdown }: Props) => { 'webchat__render-markdown', renderMarkdownStyleSet + '' )} - // The content rendered by `renderMarkdownAsHTML` is sanitized. - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ __html: renderMarkdownAsHTML(markdown) }} + ref={renderChildren} /> ) : (
{markdown}
)}
); -}); +}; CitationModalContent.displayName = 'CitationModalContent';