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( + + ![Image](https://example.com/image.png) + + ); + 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( + + ![Image](https://example.com/image.png) + + ); + 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( + + ![Image](https://cdn.example.com/image.png) + + ); + 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( + + ![Data URI](data:image/png;base64,abc123) + [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: