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 + * `", + '', + '
', + " spoofedbody", + "
", + "", +].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(" { + const html = renderMemorySummaryMarkdown(XSS_PAYLOAD); + + expect(html).not.toContain("javascript:"); + expect(html).not.toContain("onerror"); + expect(html).not.toContain("ontoggle"); + expect(html).not.toContain(" { + const html = renderArtifactMarkdown( + [ + '
centered
', + "", + "
H1
D1
", + "", + 'chart', + ].join("\n"), + ); + + expect(html).toContain('align="center"'); + expect(html).toContain(" { + 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); +});