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
5 changes: 5 additions & 0 deletions .changeset/prefix-raw-html-ids.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions packages/streamdown/__tests__/allowed-tags.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,12 @@ Content for snippet 2
</Streamdown>
);

// 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
Expand Down
67 changes: 67 additions & 0 deletions packages/streamdown/__tests__/anchor-links.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Streamdown linkSafety={{ enabled: false }} mode="static">
{
'<div id="details">Details</div>\n\n<a href="#details">Raw</a>\n\n[Prefixed](#user-content-details)\n\n[External](https://example.com/#details)'
}
</Streamdown>
);

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(
<Streamdown linkSafety={{ enabled: false }} mode="static">
{"Note[^1].\n\n[^1]: Details."}
</Streamdown>
);

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(
<Streamdown
allowedTags={allowedTags}
linkSafety={{ enabled: false }}
mode={mode}
>
{'<div id="details">Details</div>\n\n[Go](#details)'}
</Streamdown>
);

const target = container.querySelector("[id]");
const link = container.querySelector("a");

expect(target?.id).toBe("user-content-details");
expect(link?.getAttribute("href")).toBe(`#${target?.id}`);
});
}
}
});
8 changes: 6 additions & 2 deletions packages/streamdown/__tests__/custom-tag-markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,12 @@ Content for snippet 2
</Streamdown>
);

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");
Expand Down
9 changes: 9 additions & 0 deletions packages/streamdown/__tests__/footnotes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ GFM extends standard Markdown with powerful features[^1]. Here's a comprehensive
}
});

it("prefixes raw HTML ids so they cannot clobber window", () => {
const markdown = '<div id="clobbered">raw</div>';
const { container } = render(<Streamdown>{markdown}</Streamdown>);

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].
Expand Down
52 changes: 46 additions & 6 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,14 +391,52 @@ export type StreamdownProps = Options & {
| undefined;
} & AriaAttributes;

const CLOBBER_PREFIX = "user-content-";

interface HastNode {
children?: HastNode[];
properties?: { id?: unknown; href?: unknown };
type?: string;
}

/** Normalize sanitized ids and keep fragment links aligned with their targets. */
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);
}
// 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);
}
}
};
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.
Comment thread
vercel[bot] marked this conversation as resolved.
Resolved
protocols: {
...defaultSchema.protocols,
// `streamdown:` is the remend sentinel scheme for incomplete links/images
Expand All @@ -415,6 +453,7 @@ const defaultSanitizeSchema = {
export const defaultRehypePlugins: Record<string, Pluggable> = {
raw: rehypeRaw,
sanitize: [rehypeSanitize, defaultSanitizeSchema],
undoubleClobberPrefix: rehypeUndoubleClobberPrefix,

@vercel vercel Bot Oct 23, 2026 •

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.

Custom pipelines using the documented defaultRehypePlugins.sanitize entry omit normalization, leaving footnote links and backreferences pointing at nonexistent single-prefixed IDs.

Fix on Vercel

harden: [
harden,
{
Expand Down Expand Up @@ -1028,6 +1067,7 @@ export const Streamdown = memo(
result = [
defaultRehypePlugins.raw,
[rehypeSanitize, extendedSchema],
defaultRehypePlugins.undoubleClobberPrefix,
defaultRehypePlugins.harden,
];
}
Expand Down
Loading