diff --git a/.changeset/cute-deer-cut.md b/.changeset/cute-deer-cut.md
new file mode 100644
index 00000000..423490eb
--- /dev/null
+++ b/.changeset/cute-deer-cut.md
@@ -0,0 +1,5 @@
+---
+"streamdown": minor
+---
+
+migrate from harden-react-markdown to rehype-harden
diff --git a/apps/website/app/components/hardened.tsx b/apps/website/app/components/hardened.tsx
index 1c620e09..09faa214 100644
--- a/apps/website/app/components/hardened.tsx
+++ b/apps/website/app/components/hardened.tsx
@@ -28,8 +28,10 @@ export const HardenedMarkdown = () => (
}
markdown={markdown}
streamdownProps={{
- defaultOrigin: "https://streamdown.vercel.app",
- allowedLinkPrefixes: ["https://streamdown.vercel.app"],
+ hardenOptions: {
+ defaultOrigin: "https://streamdown.vercel.app",
+ allowedLinkPrefixes: ["https://streamdown.vercel.app"],
+ },
}}
title="Built-in security hardening"
/>
diff --git a/apps/website/app/components/props.tsx b/apps/website/app/components/props.tsx
index 3a5f8cc0..15388bf6 100644
--- a/apps/website/app/components/props.tsx
+++ b/apps/website/app/components/props.tsx
@@ -24,23 +24,11 @@ const props = [
"Custom React components to use for rendering markdown elements (e.g., custom heading, paragraph, code block components).",
},
{
- name: "allowedImagePrefixes",
- type: "string[]",
- default: '["*"]',
+ name: "hardenOptions",
+ type: "HardenOptions (from rehype-harden)",
+ default: `{ allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], defaultOrigin: undefined }`,
description:
- 'Array of allowed URL prefixes for images. Use ["*"] to allow all images.',
- },
- {
- name: "allowedLinkPrefixes",
- type: "string[]",
- default: '["*"]',
- description:
- 'Array of allowed URL prefixes for links. Use ["*"] to allow all links.',
- },
- {
- name: "defaultOrigin",
- type: "string",
- description: "Default origin to use for relative URLs in links and images.",
+ "Options to pass to the rehype-harden plugin for security hardening.",
},
{
name: "rehypePlugins",
diff --git a/packages/streamdown/README.md b/packages/streamdown/README.md
index 08c68887..1c9e9e5f 100644
--- a/packages/streamdown/README.md
+++ b/packages/streamdown/README.md
@@ -19,7 +19,7 @@ Streamdown powers the [AI Elements Response](https://ai-sdk.dev/elements/compone
- 🔢 **Math rendering** - LaTeX equations via KaTeX
- 📈 **Mermaid diagrams** - Render Mermaid diagrams as code blocks with a button to render them
- 🎯 **Code syntax highlighting** - Beautiful code blocks with Shiki
-- 🛡️ **Security-first** - Built on harden-react-markdown for safe rendering
+- 🛡️ **Security-first** - Built with rehype-harden for safe rendering
- ⚡ **Performance optimized** - Memoized rendering for efficient updates
## Installation
@@ -160,10 +160,8 @@ Streamdown accepts all the same props as react-markdown, plus additional streami
| `className` | `string` | - | CSS class for the container |
| `components` | `object` | - | Custom component overrides |
| `remarkPlugins` | `array` | `[remarkGfm, remarkMath]` | Remark plugins to use |
-| `rehypePlugins` | `array` | `[rehypeRaw, rehypeKatex]` | Rehype plugins to use |
-| `allowedImagePrefixes` | `array` | `['*']` | Allowed image URL prefixes |
-| `allowedLinkPrefixes` | `array` | `['*']` | Allowed link URL prefixes |
-| `defaultOrigin` | `string` | - | Default origin to use for relative URLs in links and images |
+| `rehypePlugins` | `array` | `[rehypeRaw, rehypeKatex, rehypeHarden]` | Rehype plugins to use |
+| `hardenOptions` | `HardenOptions (from rehype-harden)` | `{ allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], defaultOrigin: undefined }` | Options to pass to the rehype-harden plugin for security hardening |
| `shikiTheme` | `[BundledTheme, BundledTheme]` | `['github-light', 'github-dark']` | The light and dark themes to use for code blocks |
| `mermaidConfig` | `MermaidConfig` | - | Custom configuration for Mermaid diagrams (theme, colors, etc.) |
| `controls` | `boolean \| { table?: boolean, code?: boolean, mermaid?: boolean }` | `true` | Control visibility of copy/download buttons |
diff --git a/packages/streamdown/__tests__/email-addresses.test.tsx b/packages/streamdown/__tests__/email-addresses.test.tsx
index 4c2d38c7..fa1ce249 100644
--- a/packages/streamdown/__tests__/email-addresses.test.tsx
+++ b/packages/streamdown/__tests__/email-addresses.test.tsx
@@ -26,10 +26,6 @@ vi.mock("react-markdown", () => ({
},
}));
-vi.mock("harden-react-markdown", () => ({
- default: (Component: any) => Component,
-}));
-
vi.mock("rehype-katex", () => ({
default: () => {},
}));
diff --git a/packages/streamdown/__tests__/harden-options.test.tsx b/packages/streamdown/__tests__/harden-options.test.tsx
new file mode 100644
index 00000000..61f439b5
--- /dev/null
+++ b/packages/streamdown/__tests__/harden-options.test.tsx
@@ -0,0 +1,234 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { Streamdown } from "../index";
+
+// Mock the dependencies
+vi.mock("react-markdown", () => ({
+ default: ({ children, rehypePlugins, ...props }: any) => {
+ if (!children) {
+ return null;
+ }
+ // Store rehypePlugins in a data attribute for testing
+ return (
+
{
+ if (Array.isArray(plugin)) {
+ return {
+ name: plugin[0]?.name || "unknown",
+ options: plugin[1],
+ };
+ }
+ return { name: plugin?.name || "unknown" };
+ })
+ )}
+ {...props}
+ >
+ {children}
+
+ );
+ },
+}));
+
+vi.mock("rehype-katex", () => ({
+ default: () => {},
+}));
+
+vi.mock("remark-gfm", () => ({
+ default: () => {},
+}));
+
+vi.mock("remark-math", () => ({
+ default: () => {},
+}));
+
+vi.mock("rehype-harden", () => ({
+ harden: vi.fn((options) => {
+ const fn = () => {};
+ Object.assign(fn, { options });
+ return fn;
+ }),
+}));
+
+describe("Streamdown hardenOptions", () => {
+ it("should use default hardenOptions when not specified", () => {
+ const { container } = render(Test content);
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+
+ const rehypePlugins = JSON.parse(
+ markdown?.getAttribute("data-rehype-plugins") || "[]"
+ );
+ expect(rehypePlugins).toBeDefined();
+ });
+
+ it("should accept custom hardenOptions", () => {
+ const customOptions = {
+ allowedImagePrefixes: ["https://example.com"],
+ allowedLinkPrefixes: ["https://example.com", "mailto:"],
+ defaultOrigin: "https://example.com",
+ };
+
+ const { container } = render(
+ Test content
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should accept partial hardenOptions", () => {
+ const partialOptions = {
+ allowedLinkPrefixes: ["https://trusted.com"],
+ };
+
+ const { container } = render(
+ Test content
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should accept empty array for allowedImagePrefixes to block all images", () => {
+ const strictOptions = {
+ allowedImagePrefixes: [],
+ allowedLinkPrefixes: ["*"],
+ };
+
+ const { container } = render(
+
+ 
+
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should accept empty array for allowedLinkPrefixes to block all links", () => {
+ const strictOptions = {
+ allowedImagePrefixes: ["*"],
+ allowedLinkPrefixes: [],
+ };
+
+ const { container } = render(
+
+ [Link](https://example.com)
+
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should accept wildcard for allowedImagePrefixes", () => {
+ const wildcardOptions = {
+ allowedImagePrefixes: ["*"],
+ allowedLinkPrefixes: ["https://"],
+ };
+
+ const { container } = render(
+
+ 
+
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should accept multiple prefixes for allowedImagePrefixes", () => {
+ const multiPrefixOptions = {
+ allowedImagePrefixes: [
+ "https://cdn.example.com",
+ "https://images.example.com",
+ "data:",
+ ],
+ allowedLinkPrefixes: ["*"],
+ };
+
+ const { container } = render(
+
+ 
+
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should accept multiple prefixes for allowedLinkPrefixes", () => {
+ const multiPrefixOptions = {
+ allowedImagePrefixes: ["*"],
+ allowedLinkPrefixes: [
+ "https://example.com",
+ "https://trusted.com",
+ "mailto:",
+ "tel:",
+ ],
+ };
+
+ const { container } = render(
+
+ [Link](https://example.com)
+
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should accept defaultOrigin for relative URLs", () => {
+ const originOptions = {
+ allowedImagePrefixes: ["*"],
+ allowedLinkPrefixes: ["*"],
+ defaultOrigin: "https://example.com",
+ };
+
+ const { container } = render(
+
+ [Relative Link](/path/to/page)
+
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should work with hardenOptions and other props together", () => {
+ const customOptions = {
+ allowedImagePrefixes: ["https://"],
+ allowedLinkPrefixes: ["https://", "mailto:"],
+ defaultOrigin: "https://example.com",
+ };
+
+ const { container } = render(
+
+ # Test Content
+
+ );
+
+ const wrapper = container.firstElementChild;
+ expect(wrapper).toBeTruthy();
+ expect(wrapper?.getAttribute("class")).toContain("custom-class");
+
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+
+ it("should handle hardenOptions with special protocol prefixes", () => {
+ const specialProtocolOptions = {
+ allowedImagePrefixes: ["data:", "blob:", "https://"],
+ allowedLinkPrefixes: ["https://", "http://", "mailto:", "tel:", "#"],
+ };
+
+ const { container } = render(
+
+ 
+ [Email](mailto:test@example.com)
+ [Phone](tel:+1234567890)
+ [Anchor](#section)
+
+ );
+ const markdown = container.querySelector('[data-testid="markdown"]');
+ expect(markdown).toBeTruthy();
+ });
+});
diff --git a/packages/streamdown/__tests__/html-block-multiline.test.tsx b/packages/streamdown/__tests__/html-block-multiline.test.tsx
index 6f4757a9..1b71113c 100644
--- a/packages/streamdown/__tests__/html-block-multiline.test.tsx
+++ b/packages/streamdown/__tests__/html-block-multiline.test.tsx
@@ -2,11 +2,6 @@ import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Streamdown } from "../index";
-// Mock the dependencies
-vi.mock("harden-react-markdown", () => ({
- default: (Component: any) => Component,
-}));
-
describe("HTML Block Elements with Multiline Content - #164", () => {
it("should render multiline content inside details element", () => {
const content = `
diff --git a/packages/streamdown/__tests__/streamdown.test.tsx b/packages/streamdown/__tests__/streamdown.test.tsx
index e281870a..4f2ed510 100644
--- a/packages/streamdown/__tests__/streamdown.test.tsx
+++ b/packages/streamdown/__tests__/streamdown.test.tsx
@@ -27,10 +27,6 @@ vi.mock("react-markdown", () => ({
},
}));
-vi.mock("harden-react-markdown", () => ({
- default: (Component: any) => Component,
-}));
-
vi.mock("rehype-katex", () => ({
default: () => {},
}));
@@ -88,32 +84,16 @@ describe("Streamdown Component", () => {
expect(wrapper?.getAttribute("data-custom")).toBe("value");
});
- it("should use default allowed prefixes when not specified", () => {
- const { container } = render(Content);
- // These props are passed to child Block components, not to the wrapper div
- const markdown = container.querySelector('[data-testid="markdown"]');
- expect(markdown).toBeTruthy();
- });
-
- it("should use custom allowed prefixes when specified", () => {
- const { container } = render(
-
- Content
-
- );
- // These props are passed to child Block components, not to the wrapper div
- const markdown = container.querySelector('[data-testid="markdown"]');
- expect(markdown).toBeTruthy();
- });
+ it("should accept custom hardenOptions", () => {
+ const customOptions = {
+ allowedImagePrefixes: ["https://"],
+ allowedLinkPrefixes: ["https://", "mailto:"],
+ defaultOrigin: "https://example.com",
+ };
- it("should pass defaultOrigin prop", () => {
const { container } = render(
- Content
+ Content
);
- // This prop is passed to child Block components, not to the wrapper div
const markdown = container.querySelector('[data-testid="markdown"]');
expect(markdown).toBeTruthy();
});
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index 89e61328..474a2e19 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -8,8 +8,8 @@ import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import type { BundledTheme } from "shiki";
import "katex/dist/katex.min.css";
-import hardenReactMarkdownImport from "harden-react-markdown";
import type { MermaidConfig } from "mermaid";
+import { harden } from "rehype-harden";
import type { Options as RemarkGfmOptions } from "remark-gfm";
import type { Options as RemarkMathOptions } from "remark-math";
import { components as defaultComponents } from "./lib/components";
@@ -19,20 +19,7 @@ import { cn } from "./lib/utils";
export type { MermaidConfig } from "mermaid";
-type HardenReactMarkdownProps = Options & {
- defaultOrigin?: string;
- allowedLinkPrefixes?: string[];
- allowedImagePrefixes?: string[];
-};
-
-// Handle both ESM and CJS imports
-const hardenReactMarkdown =
- // biome-ignore lint/suspicious/noExplicitAny: "this is needed."
- (hardenReactMarkdownImport as any).default || hardenReactMarkdownImport;
-
-// Create a hardened version of ReactMarkdown
-const HardenedMarkdown: ReturnType =
- hardenReactMarkdown(ReactMarkdown);
+export type HardenOptions = Parameters[0];
export type ControlsConfig =
| boolean
@@ -42,7 +29,8 @@ export type ControlsConfig =
mermaid?: boolean;
};
-export type StreamdownProps = HardenReactMarkdownProps & {
+export type StreamdownProps = Options & {
+ hardenOptions?: HardenOptions;
parseIncompleteMarkdown?: boolean;
className?: string;
shikiTheme?: [BundledTheme, BundledTheme];
@@ -61,7 +49,7 @@ export const MermaidConfigContext = createContext(
export const ControlsContext = createContext(true);
-type BlockProps = HardenReactMarkdownProps & {
+type BlockProps = Options & {
content: string;
shouldParseIncompleteMarkdown: boolean;
};
@@ -82,7 +70,7 @@ const Block = memo(
[content, shouldParseIncompleteMarkdown]
);
- return {parsedContent};
+ return {parsedContent};
},
(prevProps, nextProps) => prevProps.content === nextProps.content
);
@@ -92,9 +80,11 @@ Block.displayName = "Block";
export const Streamdown = memo(
({
children,
- allowedImagePrefixes = ["*"],
- allowedLinkPrefixes = ["*"],
- defaultOrigin,
+ hardenOptions = {
+ allowedImagePrefixes: ["*"],
+ allowedLinkPrefixes: ["*"],
+ defaultOrigin: undefined,
+ },
parseIncompleteMarkdown: shouldParseIncompleteMarkdown = true,
components,
rehypePlugins,
@@ -125,19 +115,17 @@ export const Streamdown = memo(
{blocks.map((block, index) => (
=16.8.0'
- react-markdown: '>=9.0.0'
-
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -6989,12 +6983,6 @@ snapshots:
hachure-fill@0.5.2: {}
- harden-react-markdown@1.1.3(react-markdown@10.1.0(@types/react@19.1.12)(react@19.1.1))(react@19.1.1):
- dependencies:
- react: 19.1.1
- react-markdown: 10.1.0(@types/react@19.1.12)(react@19.1.1)
- rehype-harden: 1.1.3
-
has-flag@4.0.0: {}
hast-util-from-dom@5.0.1: