From 1bafde84e38bbf867abc830df3f5402153a9e31f Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 23 Oct 2026 13:20:27 +0200 Subject: [PATCH 1/3] fix: prefix raw HTML ids without doubling footnote clobber prefixes Restore user-content- on rehype-sanitize so raw ids cannot clobber window, then strip the doubled prefix on ids remark-rehype already prefixed. Co-authored-by: Muhammad Umer Aamir <93091750+umeraamir69@users.noreply.github.com> --- .changeset/prefix-raw-html-ids.md | 5 +++ .../__tests__/allowed-tags.test.tsx | 10 +++-- .../__tests__/custom-tag-markdown.test.tsx | 8 +++- .../streamdown/__tests__/footnotes.test.tsx | 11 +++++ packages/streamdown/index.tsx | 41 ++++++++++++++++--- 5 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 .changeset/prefix-raw-html-ids.md diff --git a/.changeset/prefix-raw-html-ids.md b/.changeset/prefix-raw-html-ids.md new file mode 100644 index 000000000..280f051d4 --- /dev/null +++ b/.changeset/prefix-raw-html-ids.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +fix: restore `user-content-` clobberPrefix on sanitize so raw HTML ids cannot clobber `window`, and strip the doubled prefix on footnote ids that remark-rehype already prefixed. diff --git a/packages/streamdown/__tests__/allowed-tags.test.tsx b/packages/streamdown/__tests__/allowed-tags.test.tsx index a941971af..ad33dadee 100644 --- a/packages/streamdown/__tests__/allowed-tags.test.tsx +++ b/packages/streamdown/__tests__/allowed-tags.test.tsx @@ -193,10 +193,12 @@ Content for snippet 2 ); - // Sanitizer's clobberPrefix is disabled to keep footnote backref hrefs in - // sync with their targets, so user-provided ids are preserved as-is. - const snippet1 = container.querySelector('[data-testid="snippet-1"]'); - const snippet2 = container.querySelector('[data-testid="snippet-2"]'); + const snippet1 = container.querySelector( + '[data-testid="snippet-user-content-1"]' + ); + const snippet2 = container.querySelector( + '[data-testid="snippet-user-content-2"]' + ); expect(snippet1).toBeTruthy(); expect(snippet2).toBeTruthy(); // Ensure snippet 2's content isn't absorbed into snippet 1 diff --git a/packages/streamdown/__tests__/custom-tag-markdown.test.tsx b/packages/streamdown/__tests__/custom-tag-markdown.test.tsx index 85661f1a9..5d36acf24 100644 --- a/packages/streamdown/__tests__/custom-tag-markdown.test.tsx +++ b/packages/streamdown/__tests__/custom-tag-markdown.test.tsx @@ -125,8 +125,12 @@ Content for snippet 2 ); - const snippet1 = container.querySelector('[data-testid="snippet-1"]'); - const snippet2 = container.querySelector('[data-testid="snippet-2"]'); + const snippet1 = container.querySelector( + '[data-testid="snippet-user-content-1"]' + ); + const snippet2 = container.querySelector( + '[data-testid="snippet-user-content-2"]' + ); expect(snippet1).toBeTruthy(); expect(snippet2).toBeTruthy(); expect(snippet1?.textContent).not.toContain("Snippet 2"); diff --git a/packages/streamdown/__tests__/footnotes.test.tsx b/packages/streamdown/__tests__/footnotes.test.tsx index f8ede71c3..f7ab2290c 100644 --- a/packages/streamdown/__tests__/footnotes.test.tsx +++ b/packages/streamdown/__tests__/footnotes.test.tsx @@ -178,6 +178,17 @@ GFM extends standard Markdown with powerful features[^1]. Here's a comprehensive } }); + it("prefixes raw HTML ids so they cannot clobber window", () => { + const markdown = '
raw
'; + const { container } = render({markdown}); + + expect(container.querySelector("#user-content-clobbered")).toBeTruthy(); + expect(container.querySelector("#clobbered")).toBeNull(); + expect( + (globalThis as { clobbered?: unknown }).clobbered + ).toBeUndefined(); + }); + it("should show footnotes once content arrives", () => { // Simulate streaming where definition has partial content const markdown = `Text with footnote[^1]. diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index e25fe043a..0a3b586c3 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -391,14 +391,41 @@ export type StreamdownProps = Options & { | undefined; } & AriaAttributes; +const CLOBBER_PREFIX = "user-content-"; + +type HastNode = { + type?: string; + properties?: { id?: unknown }; + children?: HastNode[]; +}; + +/** remark-rehype already prefixes footnote ids; sanitize prefixes again. */ +function rehypeUndoubleClobberPrefix() { + const doubled = `${CLOBBER_PREFIX}${CLOBBER_PREFIX}`; + const visit = (node: HastNode) => { + if ( + node.type === "element" && + typeof node.properties?.id === "string" && + node.properties.id.startsWith(doubled) + ) { + node.properties.id = node.properties.id.slice(CLOBBER_PREFIX.length); + } + if (Array.isArray(node.children)) { + for (const child of node.children) { + visit(child); + } + } + }; + return (tree: HastNode) => { + visit(tree); + }; +} + const defaultSanitizeSchema = { ...defaultSchema, - // remark-rehype already prefixes footnote ids and backref hrefs with - // `user-content-` (its default `clobberPrefix`). hast-util-sanitize's default - // `clobberPrefix` is also `user-content-`, which would double-prefix ids like - // `user-content-user-content-fn-1` while leaving the (already-prefixed) href - // pointing at the un-doubled anchor. Disable it here to avoid the mismatch. - clobberPrefix: "", + // Keep the default `user-content-` clobberPrefix so raw HTML ids (from + // rehype-raw) cannot clobber `window`. remark-rehype already prefixes + // footnote ids; `rehypeUndoubleClobberPrefix` strips the doubled prefix. protocols: { ...defaultSchema.protocols, // `streamdown:` is the remend sentinel scheme for incomplete links/images @@ -415,6 +442,7 @@ const defaultSanitizeSchema = { export const defaultRehypePlugins: Record = { raw: rehypeRaw, sanitize: [rehypeSanitize, defaultSanitizeSchema], + undoubleClobberPrefix: rehypeUndoubleClobberPrefix, harden: [ harden, { @@ -1028,6 +1056,7 @@ export const Streamdown = memo( result = [ defaultRehypePlugins.raw, [rehypeSanitize, extendedSchema], + defaultRehypePlugins.undoubleClobberPrefix, defaultRehypePlugins.harden, ]; } From 362601d237f491fe2f23e8ac9e55f1193c9eaf26 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 23 Oct 2026 13:20:58 +0200 Subject: [PATCH 2/3] fix: keep fragment links aligned with sanitized ids --- .../__tests__/anchor-links.test.tsx | 67 +++++++++++++++++++ packages/streamdown/index.tsx | 15 ++++- 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 packages/streamdown/__tests__/anchor-links.test.tsx diff --git a/packages/streamdown/__tests__/anchor-links.test.tsx b/packages/streamdown/__tests__/anchor-links.test.tsx new file mode 100644 index 000000000..dcd3c3c73 --- /dev/null +++ b/packages/streamdown/__tests__/anchor-links.test.tsx @@ -0,0 +1,67 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; + +describe("Sanitized fragment links", () => { + it("rewrites raw HTML fragments but leaves prefixed and external links alone", () => { + const { container } = render( + + { + '
Details
\n\nRaw\n\n[Prefixed](#user-content-details)\n\n[External](https://example.com/#details)' + } +
+ ); + + expect(container.querySelector("[id]")?.id).toBe("user-content-details"); + expect( + Array.from(container.querySelectorAll("a[href]"), (link) => + link.getAttribute("href") + ) + ).toEqual([ + "#user-content-details", + "#user-content-details", + "https://example.com/#details", + ]); + }); + + it("keeps footnote references and backrefs aligned", () => { + const { container } = render( + + {"Note[^1].\n\n[^1]: Details."} + + ); + + const links = container.querySelectorAll('a[href^="#"]'); + expect(links.length).toBe(2); + for (const link of links) { + expect( + container.querySelector(link.getAttribute("href") ?? "") + ).toBeTruthy(); + expect(link.getAttribute("href")).not.toContain( + "user-content-user-content-" + ); + } + }); + + for (const mode of ["static", "streaming"] as const) { + for (const allowedTags of [undefined, { snippet: ["id"] }]) { + it(`keeps raw HTML targets reachable in ${mode} mode (${allowedTags ? "extended" : "default"} schema)`, () => { + const { container } = render( + + {'
Details
\n\n[Go](#details)'} +
+ ); + + const target = container.querySelector("[id]"); + const link = container.querySelector("a"); + + expect(target?.id).toBe("user-content-details"); + expect(link?.getAttribute("href")).toBe(`#${target?.id}`); + }); + } + } +}); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 0a3b586c3..ef94be9f1 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -395,11 +395,11 @@ const CLOBBER_PREFIX = "user-content-"; type HastNode = { type?: string; - properties?: { id?: unknown }; + properties?: { id?: unknown; href?: unknown }; children?: HastNode[]; }; -/** remark-rehype already prefixes footnote ids; sanitize prefixes again. */ +/** Normalize sanitized ids and keep fragment links aligned with their targets. */ function rehypeUndoubleClobberPrefix() { const doubled = `${CLOBBER_PREFIX}${CLOBBER_PREFIX}`; const visit = (node: HastNode) => { @@ -410,6 +410,17 @@ function rehypeUndoubleClobberPrefix() { ) { node.properties.id = node.properties.id.slice(CLOBBER_PREFIX.length); } + // Fragment targets are sanitized too. Rewrite even when the target is in + // another streaming block, but leave already-prefixed footnote links alone. + if ( + node.type === "element" && + typeof node.properties?.href === "string" && + node.properties.href.startsWith("#") && + node.properties.href.length > 1 && + !node.properties.href.startsWith(`#${CLOBBER_PREFIX}`) + ) { + node.properties.href = `#${CLOBBER_PREFIX}${node.properties.href.slice(1)}`; + } if (Array.isArray(node.children)) { for (const child of node.children) { visit(child); From 60e78cdbccef02705847d1668e2b061c8204fe89 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 23 Oct 2026 13:52:35 +0200 Subject: [PATCH 3/3] lint: fix code style --- packages/streamdown/__tests__/footnotes.test.tsx | 4 +--- packages/streamdown/index.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/streamdown/__tests__/footnotes.test.tsx b/packages/streamdown/__tests__/footnotes.test.tsx index f7ab2290c..3c95a27df 100644 --- a/packages/streamdown/__tests__/footnotes.test.tsx +++ b/packages/streamdown/__tests__/footnotes.test.tsx @@ -184,9 +184,7 @@ GFM extends standard Markdown with powerful features[^1]. Here's a comprehensive expect(container.querySelector("#user-content-clobbered")).toBeTruthy(); expect(container.querySelector("#clobbered")).toBeNull(); - expect( - (globalThis as { clobbered?: unknown }).clobbered - ).toBeUndefined(); + expect((globalThis as { clobbered?: unknown }).clobbered).toBeUndefined(); }); it("should show footnotes once content arrives", () => { diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index ef94be9f1..a0ba7788e 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -393,11 +393,11 @@ export type StreamdownProps = Options & { const CLOBBER_PREFIX = "user-content-"; -type HastNode = { - type?: string; - properties?: { id?: unknown; href?: unknown }; +interface HastNode { children?: HastNode[]; -}; + properties?: { id?: unknown; href?: unknown }; + type?: string; +} /** Normalize sanitized ids and keep fragment links aligned with their targets. */ function rehypeUndoubleClobberPrefix() {