diff --git a/web/package-lock.json b/web/package-lock.json index eba62c68da..ca3875e27b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -76,6 +76,7 @@ "dompurify": "^3.3.2", "embla-carousel-react": "^8.6.0", "eventsource-parser": "^1.1.2", + "hast-util-sanitize": "^5.0.2", "human-id": "^4.1.1", "i18next": "^23.7.16", "i18next-browser-languagedetector": "^8.0.0", @@ -116,6 +117,7 @@ "recharts": "^2.12.4", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", @@ -15200,6 +15202,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-select": { "version": "6.0.4", "resolved": "https://registry.npmmirror.com/hast-util-select/-/hast-util-select-6.0.4.tgz", @@ -22686,6 +22703,20 @@ "url": "https://jaywcjlove.github.io/#/sponsor" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-slug": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/rehype-slug/-/rehype-slug-6.0.0.tgz", diff --git a/web/package.json b/web/package.json index df316ad15c..d996ee7434 100644 --- a/web/package.json +++ b/web/package.json @@ -86,6 +86,7 @@ "dompurify": "^3.3.2", "embla-carousel-react": "^8.6.0", "eventsource-parser": "^1.1.2", + "hast-util-sanitize": "^5.0.2", "human-id": "^4.1.1", "i18next": "^23.7.16", "i18next-browser-languagedetector": "^8.0.0", @@ -126,6 +127,7 @@ "recharts": "^2.12.4", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", diff --git a/web/src/components/floating-chat-widget-markdown.tsx b/web/src/components/floating-chat-widget-markdown.tsx index d996d85575..8c1a7f1538 100644 --- a/web/src/components/floating-chat-widget-markdown.tsx +++ b/web/src/components/floating-chat-widget-markdown.tsx @@ -54,6 +54,7 @@ import { } from 'react-syntax-highlighter/dist/esm/styles/prism'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; +import { RehypeSanitizeAssistantMarkdown } from '@/constants/markdown-rehype-plugins'; import { visitParents } from 'unist-util-visit-parents'; import styles from './floating-chat-widget-markdown.module.less'; import { useIsDarkTheme } from './theme-provider'; @@ -319,7 +320,12 @@ const FloatingChatWidgetMarkdown = ({ return (
( diff --git a/web/src/components/markdown-content/index.tsx b/web/src/components/markdown-content/index.tsx index b4913d3441..1b486a6b17 100644 --- a/web/src/components/markdown-content/index.tsx +++ b/web/src/components/markdown-content/index.tsx @@ -27,6 +27,7 @@ import Markdown from 'react-markdown'; import SyntaxHighlighter from 'react-syntax-highlighter'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; +import { RehypeSanitizeAssistantMarkdown } from '@/constants/markdown-rehype-plugins'; import { visitParents } from 'unist-util-visit-parents'; import { useTranslation } from 'react-i18next'; @@ -83,7 +84,12 @@ const rehypeWrapReference = () => { }; }; -const MarkdownRehypePlugins = [rehypeRaw, rehypeWrapReference, rehypeKatex]; +const MarkdownRehypePlugins = [ + rehypeRaw, + RehypeSanitizeAssistantMarkdown, + rehypeWrapReference, + rehypeKatex, +]; const MarkdownParagraph = ({ children, ...props }: any) => (

{children}

diff --git a/web/src/components/next-markdown-content/index.tsx b/web/src/components/next-markdown-content/index.tsx index b2cea608c2..8ca02a0808 100644 --- a/web/src/components/next-markdown-content/index.tsx +++ b/web/src/components/next-markdown-content/index.tsx @@ -28,6 +28,7 @@ import Markdown, { defaultUrlTransform } from 'react-markdown'; import SyntaxHighlighter from 'react-syntax-highlighter'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; +import { RehypeSanitizeAssistantMarkdown } from '@/constants/markdown-rehype-plugins'; import { visitParents } from 'unist-util-visit-parents'; import { useTranslation } from 'react-i18next'; @@ -390,7 +391,12 @@ function MarkdownContent({ return (
{ if ( diff --git a/web/src/constants/markdown-rehype-plugins.ts b/web/src/constants/markdown-rehype-plugins.ts new file mode 100644 index 0000000000..b7cc8327f7 --- /dev/null +++ b/web/src/constants/markdown-rehype-plugins.ts @@ -0,0 +1,50 @@ +import { defaultSchema } from 'hast-util-sanitize'; +import rehypeSanitize from 'rehype-sanitize'; +import type { PluggableList } from 'unified'; + +/** + * Allow-list for assistant-rendered markdown. + * + * The renderers run `rehype-raw`, so raw HTML inside a message becomes real + * nodes. Sanitizing the markdown *string* before that cannot hold: + * `preprocessLaTeX()` decodes `<`/`>`/`&` back into markup afterwards, + * so an entity-encoded payload is inert while the allow-list inspects it and + * live again by the time rehype-raw parses it. Sanitizing the tree closes that + * gap, and it leaves text nodes alone -- which a string-level pass cannot do, + * because escaping `<` breaks `$a < b$` for KaTeX and drops `Array` + * inside code fences. + */ +export const MarkdownSanitizeSchema = { + ...defaultSchema, + tagNames: [ + ...(defaultSchema.tagNames ?? []), + // wrappers injected by replaceThinkToSection / replaceRetrievingToSection + 'think', + 'retrieving', + 'section', + 'details', + 'summary', + ], + attributes: { + ...defaultSchema.attributes, + '*': [...(defaultSchema.attributes?.['*'] ?? []), 'className'], + }, + protocols: { + ...defaultSchema.protocols, + // Assistant answers legitimately embed inline images as `data:` URLs + // (base64 charts, screenshots). `img` is the only src-bearing tag left in + // the allow-list -- script/iframe/object/embed are not in `tagNames` -- and + // a `data:` payload loaded through cannot execute script, so this + // re-enables inline images without re-opening a script sink. + src: [...(defaultSchema.protocols?.src ?? []), 'data'], + }, +}; + +/** + * Apply the allow-list to the parsed tree. Keep this after `rehype-raw` and + * before `rehype-katex`: KaTeX emits markup the allow-list does not cover. + */ +export const RehypeSanitizeAssistantMarkdown: PluggableList[number] = [ + rehypeSanitize, + MarkdownSanitizeSchema, +]; diff --git a/web/src/pages/next-search/markdown-content/index.tsx b/web/src/pages/next-search/markdown-content/index.tsx index 72925d8799..4671ef2cbc 100644 --- a/web/src/pages/next-search/markdown-content/index.tsx +++ b/web/src/pages/next-search/markdown-content/index.tsx @@ -25,6 +25,7 @@ import Markdown from 'react-markdown'; import SyntaxHighlighter from 'react-syntax-highlighter'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; +import { RehypeSanitizeAssistantMarkdown } from '@/constants/markdown-rehype-plugins'; import { visitParents } from 'unist-util-visit-parents'; import { useTranslation } from 'react-i18next'; @@ -276,7 +277,12 @@ const MarkdownContent = ({ className="[&>section.think]:pl-[10px] [&>section.think]:text-[#8b8b8b] [&>section.think]:border-l-2 [&>section.think]:border-l-[#d5d3d3] [&>section.think]:mb-[10px] [&>section.think]:text-xs [&>blockquote]:pl-[10px] [&>blockquote]:border-l-4 [&>blockquote]:border-l-[#ccc] text-sm" >