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"
>