mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
fix(frontend): restore sanitization in custom streamdown rehype chains (#4987)
* fix(frontend): restore sanitization in custom streamdown rehype chains Streamdown 2.5 replaces its entire default rehype chain [rehype-raw, rehype-sanitize, rehype-harden] with whatever array the caller passes via the rehypePlugins prop. Every custom chain in this repo therefore rendered LLM/stored markdown without any sanitization: - Artifact markdown previews (markdown-preview-plugins.ts + artifact-file-detail.tsx) parse raw HTML via rehypeRaw, so a generated .md artifact could inject <style>/<iframe>/on* handlers into the workspace DOM (stored XSS; only javascript: anchors were blocked by the ArtifactLink component). - The memory settings summary (memory-settings-page.tsx) spread the shared preset without component overrides, so a hostile <a href="javascript:..."> in stored memory content rendered as a clickable anchor. Fix strategy: - Add rehype-sanitize (already resolved in the lockfile via streamdown) as a direct dependency and re-insert a [rehypeSanitize, schema] step in the shared preset (core/streamdown/plugins.ts). It runs after rehypeRaw (raw HTML must be parsed into hast before it can be cleaned) and before rehypeKatex/rehypeSlug (their output is trusted and would otherwise be filtered or clobbered) - the same raw -> sanitize -> math ordering streamdown itself uses. - The schema extends rehype-sanitize's GitHub-style defaultSchema (the base of streamdown's own sanitize schema) so legitimate authored artifact HTML (tables, details, images, alignment/size attributes) keeps working while script/iframe/style, on* handlers and non-allow-listed URL schemes (javascript:, data:, ...) are dropped. The only extensions are tel: hrefs and the math-inline/math-display class markers remark-math emits and rehype-katex detects. - Position rehypeSlug after the sanitize step in the artifact chain so sanitize's id clobbering (id="x" -> id="user-content-x") cannot break the heading anchors it creates. - Pass a: createMarkdownLinkComponent() on the memory settings page as defense in depth, matching the chat rendering path. Unit tests feed a hostile payload (<a href="javascript:...">, <img onerror>, <script>, <iframe>, <style>, ontoggle) through both render paths and assert no executable/clickable equivalent survives, plus regression guards for heading anchors, legitimate HTML and KaTeX math rendering. * fix(frontend): keep the sanitize clobber prefix on heading anchors; minimal lockfile Review follow-ups on the sanitization change: - Heading anchors: rehypeScopedSlug replaces rehype-slug in the artifact chain. It runs after the sanitize step (so raw-HTML headings are also anchored) but keeps rehype-sanitize's user-content- id prefix — an untrusted heading like "## current" cannot mint an unprefixed id="current" (the DOM-clobbering shape the sanitizer guards against). In-page fragment links are translated to the prefixed anchors so they still resolve; external URLs, bare "#", already-prefixed fragments and sanitize-prefixed raw-HTML ids are left untouched. - Lockfile: regenerated as a minimal diff — only the two direct-dependency importer entries (rehype-sanitize, github-slugger for the scoped slug) are added; the libc platform selectors on the 64 native package records are preserved byte-for-byte instead of being dropped by lockfile normalization. Full frontend suite: 1034 tests passing; tsc and prettier clean. * style: reorder github-slugger import ahead of the hast type import * test(e2e): expect the clobber-prefixed heading anchor in artifact preview The scoped slug plugin gives generated heading ids rehype-sanitize's user-content- prefix and translates fragment links to match, so the anchor-scroll test must locate the prefixed id. * fix(frontend): reset the scoped slugger per tree; keep footnote anchors single-prefixed Review follow-ups: - The scoped slug attacher holds one GithubSlugger, but streamdown caches the unified processor by plugin name, so the instance survived across parses and repeated renders of the same heading grew -1/-2 suffixes (the artifact-anchor e2e could not find the id on re-render). The transformer now resets the slugger per tree, as rehype-slug does; a regression test renders identical artifact markdown twice. - remark-rehype emits GFM footnote anchors already clobber-prefixed (user-content-fn-1); the sanitize step prefixed those ids again while their hrefs stayed single-prefixed, breaking footnote navigation in every chain built on the shared preset. A new rehypeClobberFragments step runs right after sanitize: double-prefixed ids are normalized back to one prefix, and unprefixed fragment hrefs are translated to the prefixed form (already-prefixed and external links untouched). The artifact slug now inserts after this step; covered by a footnote regression test on the shared render path. Unit suite 1036 passing; artifact-preview e2e verified locally (9/9, including the heading-anchor scroll test).
This commit is contained in:
parent
851e76661b
commit
8989173c8d
@ -67,6 +67,7 @@
|
||||
"defu": "6.1.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"gsap": "^3.13.0",
|
||||
"h3": "1.15.9",
|
||||
"hast": "^1.0.0",
|
||||
@ -85,6 +86,7 @@
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
|
||||
6
frontend/pnpm-lock.yaml
generated
6
frontend/pnpm-lock.yaml
generated
@ -152,6 +152,9 @@ importers:
|
||||
gsap:
|
||||
specifier: ^3.13.0
|
||||
version: 3.14.2
|
||||
github-slugger:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
h3:
|
||||
specifier: 1.15.9
|
||||
version: 1.15.9
|
||||
@ -200,6 +203,9 @@ importers:
|
||||
rehype-katex:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
rehype-sanitize:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
rehype-raw:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
|
||||
@ -1,15 +1,33 @@
|
||||
import rehypeSlug from "rehype-slug";
|
||||
|
||||
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import {
|
||||
rehypeClobberFragments,
|
||||
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);
|
||||
const fragmentsIndex = baseRehypePlugins.indexOf(rehypeClobberFragments);
|
||||
const after = Math.max(sanitizeIndex, fragmentsIndex);
|
||||
return after === -1 ? baseRehypePlugins.length : after + 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"],
|
||||
};
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { createMarkdownLinkComponent } from "@/components/workspace/messages/markdown-link";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { exportMemory } from "@/core/memory/api";
|
||||
import {
|
||||
@ -38,7 +39,10 @@ import type {
|
||||
MemoryFactPatchInput,
|
||||
UserMemory,
|
||||
} from "@/core/memory/types";
|
||||
import { SafeStreamdown } from "@/core/streamdown/components";
|
||||
import {
|
||||
SafeStreamdown,
|
||||
toStreamdownComponents,
|
||||
} from "@/core/streamdown/components";
|
||||
import { streamdownPlugins } from "@/core/streamdown/plugins";
|
||||
import { pathOfThread } from "@/core/threads/utils";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
@ -642,6 +646,13 @@ export function MemorySettingsPage() {
|
||||
<SafeStreamdown
|
||||
className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||
{...streamdownPlugins}
|
||||
components={toStreamdownComponents({
|
||||
// Defense in depth on top of the rehype-sanitize step in
|
||||
// streamdownPlugins: memory summaries are LLM/stored
|
||||
// content, so never render an unsafe href (javascript:,
|
||||
// data:, …) as a clickable anchor.
|
||||
a: createMarkdownLinkComponent(),
|
||||
})}
|
||||
>
|
||||
{summariesToMarkdown(memory, filteredSectionGroups, t)}
|
||||
</SafeStreamdown>
|
||||
|
||||
@ -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,164 @@ 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 are translated by `rehypeClobberFragments` (which runs
|
||||
* right after sanitize, before this plugin). Headings whose id sanitize
|
||||
* already prefixed (raw HTML `<h2 id="x">`) keep that id.
|
||||
*/
|
||||
export function rehypeScopedSlug() {
|
||||
const slugger = new GithubSlugger();
|
||||
return (tree: Root) => {
|
||||
// Streamdown caches the unified processor by plugin name, so this
|
||||
// attacher-level slugger instance survives across parses. Without a
|
||||
// reset, rendering the same heading twice yields `heading` then
|
||||
// `heading-1` (rehype-slug resets for the same reason).
|
||||
slugger.reset();
|
||||
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)),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps fragment navigation consistent with rehype-sanitize's clobber
|
||||
* prefix. Runs immediately after `rehypeSanitizeStep` in every chain:
|
||||
*
|
||||
* - remark-rehype already emits GFM footnote anchors PRE-prefixed
|
||||
* (`id="user-content-fn-1"`, `href="#user-content-fn-1"`), and sanitize
|
||||
* prefixes the id again — producing `user-content-user-content-fn-1`
|
||||
* while the href stays single-prefixed, breaking every footnote.
|
||||
* Ids that ended up double-prefixed are normalized back to one prefix.
|
||||
* - Fragment links written without the prefix (`#foo`) can only resolve
|
||||
* against prefixed ids after sanitization, so they are translated to
|
||||
* `#user-content-foo`. Already-prefixed hrefs and external URLs are
|
||||
* untouched.
|
||||
*/
|
||||
export function rehypeClobberFragments() {
|
||||
return (tree: Root) => {
|
||||
const doublePrefix = `${CLOBBER_PREFIX}${CLOBBER_PREFIX}`;
|
||||
visit(tree, "element", (node: Element) => {
|
||||
const id = node.properties?.id;
|
||||
if (typeof id === "string" && id.startsWith(doublePrefix)) {
|
||||
node.properties.id = id.slice(CLOBBER_PREFIX.length);
|
||||
}
|
||||
});
|
||||
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 +190,14 @@ 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,
|
||||
rehypeClobberFragments,
|
||||
[rehypeKatex, katexOptions],
|
||||
] as StreamdownProps["rehypePlugins"],
|
||||
};
|
||||
@ -85,6 +254,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,
|
||||
|
||||
@ -216,7 +216,10 @@ test.describe("Artifact preview stability", () => {
|
||||
const artifactsPanel = page.locator("#artifacts");
|
||||
await expect(artifactsPanel.getByText("report.md")).toBeVisible();
|
||||
|
||||
const targetHeading = artifactsPanel.locator("h2#概述");
|
||||
// Anchors keep rehype-sanitize's user-content- clobber prefix (see
|
||||
// rehypeScopedSlug), so the heading id — and the translated fragment
|
||||
// link that scrolls to it — are both prefixed.
|
||||
const targetHeading = artifactsPanel.locator("h2#user-content-概述");
|
||||
await expect(targetHeading).toHaveCount(1);
|
||||
await artifactsPanel.getByRole("link", { name: "概述" }).click();
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { artifactMarkdownPlugins } from "@/components/workspace/artifacts/markdown-preview-plugins";
|
||||
import { ArtifactLink } from "@/components/workspace/citations/artifact-link";
|
||||
import { createMarkdownLinkComponent } from "@/components/workspace/messages/markdown-link";
|
||||
import {
|
||||
SafeStreamdown,
|
||||
streamdownPlugins,
|
||||
@ -29,16 +30,91 @@ function renderSharedMarkdown(content: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the memory settings page: shared preset plus the safe link
|
||||
// component it passes for stored/LLM-generated summary content.
|
||||
function renderMemorySummaryMarkdown(content: string) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
SafeStreamdown,
|
||||
{
|
||||
...streamdownPlugins,
|
||||
components: toStreamdownComponents({
|
||||
a: createMarkdownLinkComponent(),
|
||||
}),
|
||||
},
|
||||
content,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test("adds GitHub-style heading anchors to artifact markdown previews", () => {
|
||||
const html = renderArtifactMarkdown(
|
||||
["[概述](#概述)", "", "## 概述"].join("\n"),
|
||||
);
|
||||
|
||||
expect(html).toContain('href="#%E6%A6%82%E8%BF%B0"');
|
||||
expect(html).toContain('id="概述"');
|
||||
// Anchors keep sanitize's user-content- clobber prefix; fragment links
|
||||
// are translated to the prefixed id so they still resolve.
|
||||
expect(html).toContain('href="#user-content-%E6%A6%82%E8%BF%B0"');
|
||||
expect(html).toContain('id="user-content-概述"');
|
||||
expect(html).not.toContain('id="概述"');
|
||||
expect(html).not.toContain("target=");
|
||||
});
|
||||
|
||||
test("scoped heading anchors cannot mint clobberable ids", () => {
|
||||
const html = renderArtifactMarkdown(
|
||||
["## current", "", "[go](#current)", "", "## forms"].join("\n"),
|
||||
);
|
||||
|
||||
// `id="current"` on a heading is the DOM-clobbering shape rehype-sanitize
|
||||
// guards against; the scoped slug must keep the user-content- prefix.
|
||||
expect(html).toContain('id="user-content-current"');
|
||||
expect(html).not.toContain('id="current"');
|
||||
// In-page fragment links are translated to the prefixed anchors.
|
||||
expect(html).toContain('href="#user-content-current"');
|
||||
expect(html).not.toContain('href="#current"');
|
||||
});
|
||||
|
||||
test("identical artifact markdown renders stable anchors across renders", () => {
|
||||
// Streamdown caches the unified processor by plugin name, so the scoped
|
||||
// slug's slugger survives across parses — it must reset per tree or the
|
||||
// second render of the same heading gets a -1 suffix that keeps growing.
|
||||
const content = ["## Stable heading", "", "## Stable heading"].join("\n");
|
||||
const first = renderArtifactMarkdown(content);
|
||||
const second = renderArtifactMarkdown(content);
|
||||
|
||||
// Streamdown parses blocks separately, so both headings get the base
|
||||
// slug; the guard is that repeated RENDERS never grow -1/-2 suffixes.
|
||||
expect(first).toContain('id="user-content-stable-heading"');
|
||||
expect(second).toContain('id="user-content-stable-heading"');
|
||||
expect(first).not.toContain("user-content-stable-heading-1");
|
||||
expect(second).not.toContain("user-content-stable-heading-1");
|
||||
expect(second).not.toContain("user-content-stable-heading-2");
|
||||
});
|
||||
|
||||
test("footnote references survive the sanitize clobber prefix", () => {
|
||||
// remark-rehype emits footnote anchors pre-prefixed (user-content-fn-1);
|
||||
// sanitize would double-prefix the ids while leaving hrefs single-prefixed.
|
||||
// rehypeClobberFragments normalizes ids back to one prefix and translates
|
||||
// unprefixed fragment hrefs, so forward and back references keep resolving.
|
||||
const html = renderSharedMarkdown(
|
||||
[
|
||||
"Body with a note[^1] and a second[^2].",
|
||||
"",
|
||||
"[^1]: First note.",
|
||||
"[^2]: Second note.",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// Streamdown renders footnote refs/backrefs as its link component
|
||||
// (buttons), so the DOM contract is the id side: single clobber prefix on
|
||||
// the footnote/list ids, never double, matching the reference hrefs the
|
||||
// link component receives (#user-content-fn-1 before React mapping).
|
||||
expect(html).not.toContain("user-content-user-content-");
|
||||
expect(html).toContain('id="user-content-fn-1"');
|
||||
expect(html).toContain('id="user-content-fn-2"');
|
||||
expect(html).toContain('id="user-content-footnote-label"');
|
||||
});
|
||||
|
||||
test("does not add heading anchors to the shared streamdown plugin config", () => {
|
||||
const html = [
|
||||
renderSharedMarkdown("## Summary"),
|
||||
@ -47,3 +123,85 @@ test("does not add heading anchors to the shared streamdown plugin config", () =
|
||||
|
||||
expect(html).not.toContain('id="summary"');
|
||||
});
|
||||
|
||||
// Stored-XSS payload: hostile markdown that must not degrade into an
|
||||
// executable/clickable DOM in any render path. Streamdown@2.5 replaces its
|
||||
// default [rehype-raw, rehype-sanitize, rehype-harden] chain with whatever
|
||||
// rehypePlugins the caller passes, so the custom chains under test must
|
||||
// carry their own sanitize step (core/streamdown/plugins.ts).
|
||||
const XSS_PAYLOAD = [
|
||||
'<a href="javascript:alert(1)">click-me</a>',
|
||||
'<img src="x" onerror="alert(2)" />',
|
||||
"<script>alert(3)</script>",
|
||||
'<iframe src="https://evil.example"></iframe>',
|
||||
'<details style="position:fixed" ontoggle="alert(4)">',
|
||||
" <summary>spoofed</summary>body",
|
||||
"</details>",
|
||||
"<style>body { background: red }</style>",
|
||||
].join("\n");
|
||||
|
||||
test("sanitizes hostile HTML in artifact markdown previews", () => {
|
||||
const html = renderArtifactMarkdown(XSS_PAYLOAD);
|
||||
|
||||
// No executable or clickable equivalents survive.
|
||||
expect(html).not.toContain("javascript:");
|
||||
expect(html).not.toContain("onerror");
|
||||
expect(html).not.toContain("ontoggle");
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toContain("<iframe");
|
||||
expect(html).not.toContain("<style");
|
||||
// `script` is stripped with its children; iframe/style are unwrapped.
|
||||
expect(html).not.toContain("alert(3)");
|
||||
// CSS injection for UI spoofing is dropped (attribute and tag).
|
||||
expect(html).not.toContain("position:fixed");
|
||||
// Legitimate authored HTML and the visible link label survive.
|
||||
expect(html).toContain("<details");
|
||||
expect(html).toContain("<summary");
|
||||
expect(html).toContain("click-me");
|
||||
});
|
||||
|
||||
test("sanitizes hostile HTML in memory summary markdown", () => {
|
||||
const html = renderMemorySummaryMarkdown(XSS_PAYLOAD);
|
||||
|
||||
expect(html).not.toContain("javascript:");
|
||||
expect(html).not.toContain("onerror");
|
||||
expect(html).not.toContain("ontoggle");
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toContain("<iframe");
|
||||
expect(html).not.toContain("<style");
|
||||
expect(html).not.toContain("alert(3)");
|
||||
expect(html).not.toContain("position:fixed");
|
||||
// The link label stays visible but never becomes a javascript: anchor
|
||||
// (streamdown's image component legitimately emits href="x" preloads).
|
||||
expect(html).toContain("click-me");
|
||||
expect(html).not.toContain('href="javascript');
|
||||
});
|
||||
|
||||
test("sanitize step preserves legitimate artifact HTML", () => {
|
||||
const html = renderArtifactMarkdown(
|
||||
[
|
||||
'<div align="center">centered</div>',
|
||||
"",
|
||||
"<table><thead><tr><th>H1</th></tr></thead><tbody><tr><td>D1</td></tr></tbody></table>",
|
||||
"",
|
||||
'<img src="https://example.com/chart.png" alt="chart" width="100" />',
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(html).toContain('align="center"');
|
||||
expect(html).toContain("<table");
|
||||
expect(html).toContain("<th");
|
||||
expect(html).toContain("<td");
|
||||
expect(html).toContain('src="https://example.com/chart.png"');
|
||||
expect(html).toContain('width="100"');
|
||||
});
|
||||
|
||||
test("sanitize step does not break KaTeX math rendering", () => {
|
||||
const html = renderArtifactMarkdown(
|
||||
["Inline $x^2$ math", "", "$$", "E=mc^2", "$$"].join("\n"),
|
||||
);
|
||||
|
||||
// rehype-katex runs after the sanitize step; its output must still be
|
||||
// produced (both inline and display math markers survive sanitization).
|
||||
expect(html.match(/class="katex"/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user