Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions frontend/pnpm-lock.yaml

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
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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>
Expand Down
142 changes: 141 additions & 1 deletion frontend/src/core/streamdown/plugins.ts
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";
Expand All @@ -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();

Copy link
Copy Markdown
Contributor

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 GithubSlugger instance survives across parses. Rendering the same ## Stable heading twice produces user-content-stable-heading and then user-content-stable-heading-1; subsequent renders keep incrementing. This is why the updated artifact-anchor E2E currently cannot find h2#user-content-概述. Please call slugger.reset() at the start of the returned tree transformer (as rehype-slug does) and add a regression test that renders identical artifact Markdown twice.

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 }],
Expand All @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 href="#user-content-fn-1" and id="user-content-fn-1". This sanitize step applies the default clobber prefix to the ID again, yielding id="user-content-user-content-fn-1", but leaves the href unchanged; backreferences break the same way. Because this is in the shared preset, [^1] navigation is broken in chat, memory summaries, and artifacts. Please add target-aware fragment remapping after sanitization (or otherwise make the clobber strategy compatible with generated footnotes) and cover the forward and backreference links in a regression test.

[rehypeKatex, katexOptions],
] as StreamdownProps["rehypePlugins"],
};
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion frontend/tests/e2e/artifact-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,11 @@
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);

Check failure on line 223 in frontend/tests/e2e/artifact-preview.spec.ts

View workflow job for this annotation

GitHub Actions / e2e-tests

[chromium] › tests/e2e/artifact-preview.spec.ts:178:3 › Artifact preview stability › scrolls markdown artifact preview to heading anchors

1) [chromium] › tests/e2e/artifact-preview.spec.ts:178:3 › Artifact preview stability › scrolls markdown artifact preview to heading anchors Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveCount(expected) failed Locator: locator('#artifacts').locator('h2#user-content-概述') Expected: 1 Received: 0 Timeout: 5000ms Call log: - Expect "toHaveCount" with timeout 5000ms - waiting for locator('#artifacts').locator('h2#user-content-概述') 9 × locator resolved to 0 elements - unexpected value "0" 221 | // link that scrolls to it — are both prefixed. 222 | const targetHeading = artifactsPanel.locator("h2#user-content-概述"); > 223 | await expect(targetHeading).toHaveCount(1); | ^ 224 | await artifactsPanel.getByRole("link", { name: "概述" }).click(); 225 | 226 | await expect at /home/runner/work/deer-flow/deer-flow/frontend/tests/e2e/artifact-preview.spec.ts:223:33

Check failure on line 223 in frontend/tests/e2e/artifact-preview.spec.ts

View workflow job for this annotation

GitHub Actions / e2e-tests

[chromium] › tests/e2e/artifact-preview.spec.ts:178:3 › Artifact preview stability › scrolls markdown artifact preview to heading anchors

1) [chromium] › tests/e2e/artifact-preview.spec.ts:178:3 › Artifact preview stability › scrolls markdown artifact preview to heading anchors Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveCount(expected) failed Locator: locator('#artifacts').locator('h2#user-content-概述') Expected: 1 Received: 0 Timeout: 5000ms Call log: - Expect "toHaveCount" with timeout 5000ms - waiting for locator('#artifacts').locator('h2#user-content-概述') 9 × locator resolved to 0 elements - unexpected value "0" 221 | // link that scrolls to it — are both prefixed. 222 | const targetHeading = artifactsPanel.locator("h2#user-content-概述"); > 223 | await expect(targetHeading).toHaveCount(1); | ^ 224 | await artifactsPanel.getByRole("link", { name: "概述" }).click(); 225 | 226 | await expect at /home/runner/work/deer-flow/deer-flow/frontend/tests/e2e/artifact-preview.spec.ts:223:33

Check failure on line 223 in frontend/tests/e2e/artifact-preview.spec.ts

View workflow job for this annotation

GitHub Actions / e2e-tests

[chromium] › tests/e2e/artifact-preview.spec.ts:178:3 › Artifact preview stability › scrolls markdown artifact preview to heading anchors

1) [chromium] › tests/e2e/artifact-preview.spec.ts:178:3 › Artifact preview stability › scrolls markdown artifact preview to heading anchors Error: expect(locator).toHaveCount(expected) failed Locator: locator('#artifacts').locator('h2#user-content-概述') Expected: 1 Received: 0 Timeout: 5000ms Call log: - Expect "toHaveCount" with timeout 5000ms - waiting for locator('#artifacts').locator('h2#user-content-概述') 9 × locator resolved to 0 elements - unexpected value "0" 221 | // link that scrolls to it — are both prefixed. 222 | const targetHeading = artifactsPanel.locator("h2#user-content-概述"); > 223 | await expect(targetHeading).toHaveCount(1); | ^ 224 | await artifactsPanel.getByRole("link", { name: "概述" }).click(); 225 | 226 | await expect at /home/runner/work/deer-flow/deer-flow/frontend/tests/e2e/artifact-preview.spec.ts:223:33
await artifactsPanel.getByRole("link", { name: "概述" }).click();

await expect
Expand Down
Loading
Loading