-
Notifications
You must be signed in to change notification settings - Fork 11.2k
fix(frontend): restore sanitization in custom streamdown rehype chains #4987
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
3939326
7130e00
ea48309
51a64be
cc38bd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,30 @@ | ||
| import rehypeSlug from "rehype-slug"; | ||
|
|
||
| import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown"; | ||
| import { streamdownPlugins } from "@/core/streamdown"; | ||
| import { | ||
| rehypeSanitizeStep, | ||
| rehypeScopedSlug, | ||
| streamdownPlugins, | ||
| } from "@/core/streamdown"; | ||
|
|
||
| const baseRehypePlugins = streamdownPlugins.rehypePlugins ?? []; | ||
|
|
||
| // Insert the scoped slug plugin immediately after the sanitize step: it | ||
| // runs after sanitize on purpose (so it also sees headings authored as raw | ||
| // HTML once rehypeRaw has parsed them) while PRESERVING sanitize's | ||
| // `user-content-` id clobber prefix on the anchors it generates — see | ||
| // rehypeScopedSlug. rehypeKatex stays after both so the sanitize schema | ||
| // never filters KaTeX's trusted output. If the sanitize entry is ever | ||
| // absent, appending the slug plugin last keeps a sane (if less strict) | ||
| // chain. | ||
| const slugInsertionIndex = (() => { | ||
| const sanitizeIndex = baseRehypePlugins.indexOf(rehypeSanitizeStep); | ||
| return sanitizeIndex === -1 ? baseRehypePlugins.length : sanitizeIndex + 1; | ||
| })(); | ||
|
|
||
| export const artifactMarkdownPlugins = { | ||
| ...streamdownPlugins, | ||
| rehypePlugins: [ | ||
| ...baseRehypePlugins.slice(0, 1), | ||
| rehypeSlug, | ||
| ...baseRehypePlugins.slice(1), | ||
| ...baseRehypePlugins.slice(0, slugInsertionIndex), | ||
| rehypeScopedSlug, | ||
| ...baseRehypePlugins.slice(slugInsertionIndex), | ||
| ] as ClipboardSafeStreamdownProps["rehypePlugins"], | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,13 @@ | ||
| import { code } from "@streamdown/code"; | ||
| import { mermaid } from "@streamdown/mermaid"; | ||
| import type { Root } from "hast"; | ||
| import GithubSlugger from "github-slugger"; | ||
| import type { Element, Nodes, Root } from "hast"; | ||
| import rehypeKatex from "rehype-katex"; | ||
| import rehypeRaw from "rehype-raw"; | ||
| import rehypeSanitize, { | ||
| defaultSchema, | ||
| type Options as SanitizeOptions, | ||
| } from "rehype-sanitize"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import remarkMath from "remark-math"; | ||
| import type { StreamdownProps } from "streamdown"; | ||
|
|
@@ -14,6 +19,134 @@ const katexOptions = { | |
| strict: false, | ||
| } as const; | ||
|
|
||
| type RehypePlugin = NonNullable<StreamdownProps["rehypePlugins"]>[number]; | ||
|
|
||
| /** | ||
| * Schema for the rehype-sanitize step that every custom rehype chain below | ||
| * re-applies. | ||
| * | ||
| * Why an explicit sanitize step is needed at all: streamdown@2.5 swaps its | ||
| * whole default rehype chain `[rehype-raw, rehype-sanitize, rehype-harden]` | ||
| * for the caller's array as soon as a `rehypePlugins` prop is passed. Any | ||
| * custom chain therefore silently loses sanitization unless it re-adds one. | ||
| * | ||
| * The schema starts from rehype-sanitize's GitHub-style `defaultSchema` | ||
| * (the same base streamdown's built-in sanitize step uses): it keeps the | ||
| * legitimate HTML that LLM/authored markdown documents may embed — tables, | ||
| * `<details>`, images, alignment/size attributes, … — while dropping | ||
| * `<script>`, `<iframe>`, `<style>`, `on*` event handlers and non-allow-listed | ||
| * URL schemes such as `javascript:` (`href` is limited to http(s)/mailto/tel | ||
| * and relative references). | ||
| * | ||
| * Extensions over the plain default schema: | ||
| * - `tel:` hrefs — mirrors streamdown's built-in schema and the scheme | ||
| * allow-list in `isSafeHref` (markdown-link.tsx). | ||
| * - `math-inline` / `math-display` values for `className` on `code` — | ||
| * remark-math marks math spans as `<code class="language-math | ||
| * math-inline|math-display">` and rehype-katex (which runs *after* the | ||
| * sanitize step, see `rehypeSanitizeStep`) detects math through exactly | ||
| * those classes. Without this entry sanitize strips the markers and math | ||
| * stops rendering. hast-util-sanitize only honors the first definition | ||
| * per property name, so the default `^language-.` allow-list is widened | ||
| * in place rather than appended to. | ||
| * - `metastring` on `code` — parity with streamdown's built-in schema. | ||
| * | ||
| * Deliberately NOT extended with the `style` attribute/tag, `iframe`, or | ||
| * arbitrary `className` values: CSS injection enables UI spoofing and the | ||
| * GitHub allow-list already covers what authored markdown legitimately | ||
| * needs. | ||
| */ | ||
| const sanitizeSchema: SanitizeOptions = { | ||
| ...defaultSchema, | ||
| protocols: { | ||
| ...defaultSchema.protocols, | ||
| href: [...(defaultSchema.protocols?.href ?? []), "tel"], | ||
| }, | ||
| attributes: { | ||
| ...defaultSchema.attributes, | ||
| code: [ | ||
| ["className", /^language-./, "math-inline", "math-display"], | ||
| "metastring", | ||
| ], | ||
| }, | ||
| }; | ||
|
|
||
| /** | ||
| * The sanitize entry re-inserted into every custom rehype plugin chain. | ||
| * | ||
| * Ordering constraints (streamdown's own default chain has the same shape: | ||
| * raw → sanitize, with its math rehype plugin appended after sanitize): | ||
| * - AFTER `rehypeRaw`: raw HTML must first be parsed into hast nodes; | ||
| * before that it is inert text and cannot be sanitized. | ||
| * - BEFORE `rehypeKatex`: KaTeX emits class/style-heavy trusted markup that | ||
| * the sanitize schema would strip, breaking math rendering. | ||
| * - In the artifact chain, `rehypeScopedSlug` also runs after this step so | ||
| * generated heading ids keep sanitize's `user-content-` clobber prefix | ||
| * (and fragment links are translated to match). | ||
| */ | ||
| export const rehypeSanitizeStep = [ | ||
| rehypeSanitize, | ||
| sanitizeSchema, | ||
| ] as RehypePlugin; | ||
|
|
||
| /** The id prefix rehype-sanitize applies to guard against DOM clobbering. */ | ||
| const CLOBBER_PREFIX = defaultSchema.clobberPrefix ?? "user-content-"; | ||
|
|
||
| function nodeText(node: Nodes): string { | ||
| if (node.type === "text") { | ||
| return node.value; | ||
| } | ||
| if ("children" in node) { | ||
| return node.children.map(nodeText).join(""); | ||
| } | ||
| return ""; | ||
| } | ||
|
|
||
| /** | ||
| * Heading-anchor plugin for chains that run AFTER `rehypeSanitizeStep`. | ||
| * | ||
| * rehype-sanitize prefixes `id` attributes (default `user-content-`) so a | ||
| * hostile heading such as `## current` cannot mint an unprefixed | ||
| * `id="current"` — the exact DOM-clobbering shape the sanitizer guards | ||
| * against. A slug plugin running after sanitize must therefore keep that | ||
| * prefix: generated heading ids get `CLOBBER_PREFIX + slug`, and in-page | ||
| * fragment links (`#foo`) are translated to the prefixed anchor so they | ||
| * still resolve. External URLs, bare `#`, and already-prefixed fragments | ||
| * are left untouched; headings whose id sanitize already prefixed (raw | ||
| * HTML `<h2 id="x">`) keep that id. | ||
| */ | ||
| export function rehypeScopedSlug() { | ||
| const slugger = new GithubSlugger(); | ||
| return (tree: Root) => { | ||
| visit(tree, "element", (node: Element) => { | ||
| if (!/^h[1-6]$/.test(node.tagName)) { | ||
| return; | ||
| } | ||
| if (node.properties?.id) { | ||
| return; | ||
| } | ||
| node.properties = { | ||
| ...node.properties, | ||
| id: CLOBBER_PREFIX + slugger.slug(nodeText(node)), | ||
| }; | ||
| }); | ||
| visit(tree, "element", (node: Element) => { | ||
| if (node.tagName !== "a") { | ||
| return; | ||
| } | ||
| const href = node.properties?.href; | ||
| if ( | ||
| typeof href === "string" && | ||
| href.length > 1 && | ||
| href.startsWith("#") && | ||
| !href.startsWith(`#${CLOBBER_PREFIX}`) | ||
| ) { | ||
| node.properties.href = `#${CLOBBER_PREFIX}${href.slice(1)}`; | ||
| } | ||
| }); | ||
| }; | ||
| } | ||
|
|
||
| const sharedRemarkPlugins = [ | ||
| [remarkGfm, { singleTilde: false }], | ||
| [remarkMath, { singleDollarTextMath: true }], | ||
|
|
@@ -27,8 +160,13 @@ export const streamdownRenderingPlugins = { | |
| export const streamdownPlugins = { | ||
| plugins: streamdownRenderingPlugins, | ||
| remarkPlugins: sharedRemarkPlugins, | ||
| // Passing rehypePlugins to streamdown drops its default sanitize chain, | ||
| // so every chain built from this preset carries rehypeSanitizeStep after | ||
| // rehypeRaw and before rehypeKatex (see rehypeSanitizeStep for why the | ||
| // order matters). | ||
| rehypePlugins: [ | ||
| rehypeRaw, | ||
| rehypeSanitizeStep, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Keep generated footnote hrefs aligned with sanitized IDs Remark-rehype already emits footnote pairs such as |
||
| [rehypeKatex, katexOptions], | ||
| ] as StreamdownProps["rehypePlugins"], | ||
| }; | ||
|
|
@@ -85,6 +223,8 @@ export function rehypeStreamingListItems() { | |
| }; | ||
| } | ||
|
|
||
| // Same chain minus rehypeRaw, so raw HTML stays inert text; the sanitize | ||
| // step survives the filter and still cleans autolink URLs etc. | ||
| export const streamdownPluginsWithoutRawHtml = { | ||
| plugins: streamdownPlugins.plugins, | ||
| remarkPlugins: streamdownPlugins.remarkPlugins, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Reset the slugger for every transformed tree
Streamdown caches the unified processor by plugin name, so this
GithubSluggerinstance survives across parses. Rendering the same## Stable headingtwice producesuser-content-stable-headingand thenuser-content-stable-heading-1; subsequent renders keep incrementing. This is why the updated artifact-anchor E2E currently cannot findh2#user-content-概述. Please callslugger.reset()at the start of the returned tree transformer (asrehype-slugdoes) and add a regression test that renders identical artifact Markdown twice.