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/cute-deer-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": minor
---

migrate from harden-react-markdown to rehype-harden
6 changes: 4 additions & 2 deletions apps/website/app/components/hardened.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand Down
20 changes: 4 additions & 16 deletions apps/website/app/components/props.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 3 additions & 5 deletions packages/streamdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 0 additions & 4 deletions packages/streamdown/__tests__/email-addresses.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ vi.mock("react-markdown", () => ({
},
}));

vi.mock("harden-react-markdown", () => ({
default: (Component: any) => Component,
}));

vi.mock("rehype-katex", () => ({
default: () => {},
}));
Expand Down
234 changes: 234 additions & 0 deletions packages/streamdown/__tests__/harden-options.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-testid="markdown"
data-rehype-plugins={JSON.stringify(
rehypePlugins?.map((plugin: any) => {
if (Array.isArray(plugin)) {
return {
name: plugin[0]?.name || "unknown",
options: plugin[1],
};
}
return { name: plugin?.name || "unknown" };
})
)}
{...props}
>
{children}
</div>
);
},
}));

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(<Streamdown>Test content</Streamdown>);
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(
<Streamdown hardenOptions={customOptions}>Test content</Streamdown>
);
const markdown = container.querySelector('[data-testid="markdown"]');
expect(markdown).toBeTruthy();
});

it("should accept partial hardenOptions", () => {
const partialOptions = {
allowedLinkPrefixes: ["https://trusted.com"],
};

const { container } = render(
<Streamdown hardenOptions={partialOptions}>Test content</Streamdown>
);
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(
<Streamdown hardenOptions={strictOptions}>
![Image](https://example.com/image.png)
</Streamdown>
);
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(
<Streamdown hardenOptions={strictOptions}>
[Link](https://example.com)
</Streamdown>
);
const markdown = container.querySelector('[data-testid="markdown"]');
expect(markdown).toBeTruthy();
});

it("should accept wildcard for allowedImagePrefixes", () => {
const wildcardOptions = {
allowedImagePrefixes: ["*"],
allowedLinkPrefixes: ["https://"],
};

const { container } = render(
<Streamdown hardenOptions={wildcardOptions}>
![Image](https://example.com/image.png)
</Streamdown>
);
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(
<Streamdown hardenOptions={multiPrefixOptions}>
![Image](https://cdn.example.com/image.png)
</Streamdown>
);
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(
<Streamdown hardenOptions={multiPrefixOptions}>
[Link](https://example.com)
</Streamdown>
);
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(
<Streamdown hardenOptions={originOptions}>
[Relative Link](/path/to/page)
</Streamdown>
);
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(
<Streamdown
hardenOptions={customOptions}
className="custom-class"
parseIncompleteMarkdown={true}
>
# Test Content
</Streamdown>
);

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(
<Streamdown hardenOptions={specialProtocolOptions}>
![Data URI](data:image/png;base64,abc123)
[Email](mailto:test@example.com)
[Phone](tel:+1234567890)
[Anchor](#section)
</Streamdown>
);
const markdown = container.querySelector('[data-testid="markdown"]');
expect(markdown).toBeTruthy();
});
});
5 changes: 0 additions & 5 deletions packages/streamdown/__tests__/html-block-multiline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<details>
Expand Down
34 changes: 7 additions & 27 deletions packages/streamdown/__tests__/streamdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ vi.mock("react-markdown", () => ({
},
}));

vi.mock("harden-react-markdown", () => ({
default: (Component: any) => Component,
}));

vi.mock("rehype-katex", () => ({
default: () => {},
}));
Expand Down Expand Up @@ -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(<Streamdown>Content</Streamdown>);
// 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(
<Streamdown
allowedImagePrefixes={["https://", "http://"]}
allowedLinkPrefixes={["https://", "mailto:"]}
>
Content
</Streamdown>
);
// 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(
<Streamdown defaultOrigin="https://example.com">Content</Streamdown>
<Streamdown hardenOptions={customOptions}>Content</Streamdown>
);
// This prop is passed to child Block components, not to the wrapper div
const markdown = container.querySelector('[data-testid="markdown"]');
expect(markdown).toBeTruthy();
});
Expand Down
Loading
Loading