fix(web): sanitize the parsed markdown tree instead of the string (GHSA-jv5f-3mqh-cxwf) (#17590)

### Summary

This PR sanitizes the parsed tree instead of the string:
`rehype-sanitize` runs after `rehype-raw` and before `rehype-katex`,
with one shared schema.
This commit is contained in:
Jan Kahmen
2026-08-20 13:40:49 +02:00
committed by GitHub
parent 0067470903
commit c7f22d49c7
8 changed files with 117 additions and 5 deletions

31
web/package-lock.json generated
View File

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

View File

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

View File

@@ -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 (
<div className={styles['floating-chat-widget']} dir={dir}>
<Markdown
rehypePlugins={[rehypeRaw, rehypeWrapReference, rehypeKatex]}
rehypePlugins={[
rehypeRaw,
RehypeSanitizeAssistantMarkdown,
rehypeWrapReference,
rehypeKatex,
]}
remarkPlugins={MarkdownRemarkPlugins}
className="text-sm leading-relaxed space-y-2 prose-sm max-w-full"
components={

View File

@@ -24,6 +24,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 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
@@ -55,7 +56,11 @@ const HighLightMarkdown = ({
<div dir={dir} className={classNames(styles.text)}>
<Markdown
remarkPlugins={MarkdownRemarkPlugins}
rehypePlugins={[rehypeRaw, rehypeKatex]}
rehypePlugins={[
rehypeRaw,
RehypeSanitizeAssistantMarkdown,
rehypeKatex,
]}
components={
{
p: ({ children, ...props }: any) => (

View File

@@ -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) => (
<p {...props}>{children}</p>

View File

@@ -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 (
<div dir={dir} className={styles.markdownContentWrapper}>
<Markdown
rehypePlugins={[rehypeRaw, rehypeWrapReference, rehypeKatex]}
rehypePlugins={[
rehypeRaw,
RehypeSanitizeAssistantMarkdown,
rehypeWrapReference,
rehypeKatex,
]}
remarkPlugins={MarkdownRemarkPlugins}
urlTransform={(url, key) => {
if (

View File

@@ -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 `&lt;`/`&gt;`/`&amp;` 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<number>`
* 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 <img> 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,
];

View File

@@ -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"
>
<Markdown
rehypePlugins={[rehypeRaw, rehypeWrapReference, rehypeKatex]}
rehypePlugins={[
rehypeRaw,
RehypeSanitizeAssistantMarkdown,
rehypeWrapReference,
rehypeKatex,
]}
remarkPlugins={MarkdownRemarkPlugins}
components={
{