diff --git a/frontend/package.json b/frontend/package.json
index 3fc8cc35787..b1202a057c4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index a9dbac121ca..3cc5156ef12 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -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
diff --git a/frontend/src/components/workspace/artifacts/markdown-preview-plugins.ts b/frontend/src/components/workspace/artifacts/markdown-preview-plugins.ts
index 45acb7a934a..c8026856ae8 100644
--- a/frontend/src/components/workspace/artifacts/markdown-preview-plugins.ts
+++ b/frontend/src/components/workspace/artifacts/markdown-preview-plugins.ts
@@ -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"],
};
diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx
index 6f7538f8834..3a67eadeeee 100644
--- a/frontend/src/components/workspace/settings/memory-settings-page.tsx
+++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx
@@ -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() {
{summariesToMarkdown(memory, filteredSectionGroups, t)}
diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts
index 7374fc2a7c7..2ce6473587c 100644
--- a/frontend/src/core/streamdown/plugins.ts
+++ b/frontend/src/core/streamdown/plugins.ts
@@ -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[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,
+ * ``, images, alignment/size attributes, … — while dropping
+ * `",
+ '',
+ '',
+ " spoofed
body",
+ " ",
+ "",
+].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("