Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,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"],
};
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
173 changes: 172 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,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();

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) => {
// 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 }],
Expand All @@ -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,

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.

rehypeClobberFragments,
[rehypeKatex, katexOptions],
] as StreamdownProps["rehypePlugins"],
};
Expand Down Expand Up @@ -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,
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,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();

Expand Down
Loading
Loading