Skip to content

Commit 6c6f507

Browse files
Replace harden-react-markdown with rehype-harden (#170)
* replace harden-react-markdown with rehype-harden * Improve docs * Add hardenOptions prop to allow for full props * Improve unit tests for hardenOptions * Create cute-deer-cut.md * Update hardened.tsx
1 parent 20ca02d commit 6c6f507

File tree

11 files changed

+273
-99
lines changed

11 files changed

+273
-99
lines changed

.changeset/cute-deer-cut.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": minor
3+
---
4+
5+
migrate from harden-react-markdown to rehype-harden

apps/website/app/components/hardened.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ export const HardenedMarkdown = () => (
2828
}
2929
markdown={markdown}
3030
streamdownProps={{
31-
defaultOrigin: "https://streamdown.vercel.app",
32-
allowedLinkPrefixes: ["https://streamdown.vercel.app"],
31+
hardenOptions: {
32+
defaultOrigin: "https://streamdown.vercel.app",
33+
allowedLinkPrefixes: ["https://streamdown.vercel.app"],
34+
},
3335
}}
3436
title="Built-in security hardening"
3537
/>

apps/website/app/components/props.tsx

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,11 @@ const props = [
2424
"Custom React components to use for rendering markdown elements (e.g., custom heading, paragraph, code block components).",
2525
},
2626
{
27-
name: "allowedImagePrefixes",
28-
type: "string[]",
29-
default: '["*"]',
27+
name: "hardenOptions",
28+
type: "HardenOptions (from rehype-harden)",
29+
default: `{ allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], defaultOrigin: undefined }`,
3030
description:
31-
'Array of allowed URL prefixes for images. Use ["*"] to allow all images.',
32-
},
33-
{
34-
name: "allowedLinkPrefixes",
35-
type: "string[]",
36-
default: '["*"]',
37-
description:
38-
'Array of allowed URL prefixes for links. Use ["*"] to allow all links.',
39-
},
40-
{
41-
name: "defaultOrigin",
42-
type: "string",
43-
description: "Default origin to use for relative URLs in links and images.",
31+
"Options to pass to the rehype-harden plugin for security hardening.",
4432
},
4533
{
4634
name: "rehypePlugins",

packages/streamdown/README.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Streamdown powers the [AI Elements Response](https://ai-sdk.dev/elements/compone
1919
- 🔢 **Math rendering** - LaTeX equations via KaTeX
2020
- 📈 **Mermaid diagrams** - Render Mermaid diagrams as code blocks with a button to render them
2121
- 🎯 **Code syntax highlighting** - Beautiful code blocks with Shiki
22-
- 🛡️ **Security-first** - Built on harden-react-markdown for safe rendering
22+
- 🛡️ **Security-first** - Built with rehype-harden for safe rendering
2323
-**Performance optimized** - Memoized rendering for efficient updates
2424

2525
## Installation
@@ -160,10 +160,8 @@ Streamdown accepts all the same props as react-markdown, plus additional streami
160160
| `className` | `string` | - | CSS class for the container |
161161
| `components` | `object` | - | Custom component overrides |
162162
| `remarkPlugins` | `array` | `[remarkGfm, remarkMath]` | Remark plugins to use |
163-
| `rehypePlugins` | `array` | `[rehypeRaw, rehypeKatex]` | Rehype plugins to use |
164-
| `allowedImagePrefixes` | `array` | `['*']` | Allowed image URL prefixes |
165-
| `allowedLinkPrefixes` | `array` | `['*']` | Allowed link URL prefixes |
166-
| `defaultOrigin` | `string` | - | Default origin to use for relative URLs in links and images |
163+
| `rehypePlugins` | `array` | `[rehypeRaw, rehypeKatex, rehypeHarden]` | Rehype plugins to use |
164+
| `hardenOptions` | `HardenOptions (from rehype-harden)` | `{ allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], defaultOrigin: undefined }` | Options to pass to the rehype-harden plugin for security hardening |
167165
| `shikiTheme` | `[BundledTheme, BundledTheme]` | `['github-light', 'github-dark']` | The light and dark themes to use for code blocks |
168166
| `mermaidConfig` | `MermaidConfig` | - | Custom configuration for Mermaid diagrams (theme, colors, etc.) |
169167
| `controls` | `boolean \| { table?: boolean, code?: boolean, mermaid?: boolean }` | `true` | Control visibility of copy/download buttons |

packages/streamdown/__tests__/email-addresses.test.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,6 @@ vi.mock("react-markdown", () => ({
2626
},
2727
}));
2828

29-
vi.mock("harden-react-markdown", () => ({
30-
default: (Component: any) => Component,
31-
}));
32-
3329
vi.mock("rehype-katex", () => ({
3430
default: () => {},
3531
}));
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { Streamdown } from "../index";
4+
5+
// Mock the dependencies
6+
vi.mock("react-markdown", () => ({
7+
default: ({ children, rehypePlugins, ...props }: any) => {
8+
if (!children) {
9+
return null;
10+
}
11+
// Store rehypePlugins in a data attribute for testing
12+
return (
13+
<div
14+
data-testid="markdown"
15+
data-rehype-plugins={JSON.stringify(
16+
rehypePlugins?.map((plugin: any) => {
17+
if (Array.isArray(plugin)) {
18+
return {
19+
name: plugin[0]?.name || "unknown",
20+
options: plugin[1],
21+
};
22+
}
23+
return { name: plugin?.name || "unknown" };
24+
})
25+
)}
26+
{...props}
27+
>
28+
{children}
29+
</div>
30+
);
31+
},
32+
}));
33+
34+
vi.mock("rehype-katex", () => ({
35+
default: () => {},
36+
}));
37+
38+
vi.mock("remark-gfm", () => ({
39+
default: () => {},
40+
}));
41+
42+
vi.mock("remark-math", () => ({
43+
default: () => {},
44+
}));
45+
46+
vi.mock("rehype-harden", () => ({
47+
harden: vi.fn((options) => {
48+
const fn = () => {};
49+
Object.assign(fn, { options });
50+
return fn;
51+
}),
52+
}));
53+
54+
describe("Streamdown hardenOptions", () => {
55+
it("should use default hardenOptions when not specified", () => {
56+
const { container } = render(<Streamdown>Test content</Streamdown>);
57+
const markdown = container.querySelector('[data-testid="markdown"]');
58+
expect(markdown).toBeTruthy();
59+
60+
const rehypePlugins = JSON.parse(
61+
markdown?.getAttribute("data-rehype-plugins") || "[]"
62+
);
63+
expect(rehypePlugins).toBeDefined();
64+
});
65+
66+
it("should accept custom hardenOptions", () => {
67+
const customOptions = {
68+
allowedImagePrefixes: ["https://example.com"],
69+
allowedLinkPrefixes: ["https://example.com", "mailto:"],
70+
defaultOrigin: "https://example.com",
71+
};
72+
73+
const { container } = render(
74+
<Streamdown hardenOptions={customOptions}>Test content</Streamdown>
75+
);
76+
const markdown = container.querySelector('[data-testid="markdown"]');
77+
expect(markdown).toBeTruthy();
78+
});
79+
80+
it("should accept partial hardenOptions", () => {
81+
const partialOptions = {
82+
allowedLinkPrefixes: ["https://trusted.com"],
83+
};
84+
85+
const { container } = render(
86+
<Streamdown hardenOptions={partialOptions}>Test content</Streamdown>
87+
);
88+
const markdown = container.querySelector('[data-testid="markdown"]');
89+
expect(markdown).toBeTruthy();
90+
});
91+
92+
it("should accept empty array for allowedImagePrefixes to block all images", () => {
93+
const strictOptions = {
94+
allowedImagePrefixes: [],
95+
allowedLinkPrefixes: ["*"],
96+
};
97+
98+
const { container } = render(
99+
<Streamdown hardenOptions={strictOptions}>
100+
![Image](https://example.com/image.png)
101+
</Streamdown>
102+
);
103+
const markdown = container.querySelector('[data-testid="markdown"]');
104+
expect(markdown).toBeTruthy();
105+
});
106+
107+
it("should accept empty array for allowedLinkPrefixes to block all links", () => {
108+
const strictOptions = {
109+
allowedImagePrefixes: ["*"],
110+
allowedLinkPrefixes: [],
111+
};
112+
113+
const { container } = render(
114+
<Streamdown hardenOptions={strictOptions}>
115+
[Link](https://example.com)
116+
</Streamdown>
117+
);
118+
const markdown = container.querySelector('[data-testid="markdown"]');
119+
expect(markdown).toBeTruthy();
120+
});
121+
122+
it("should accept wildcard for allowedImagePrefixes", () => {
123+
const wildcardOptions = {
124+
allowedImagePrefixes: ["*"],
125+
allowedLinkPrefixes: ["https://"],
126+
};
127+
128+
const { container } = render(
129+
<Streamdown hardenOptions={wildcardOptions}>
130+
![Image](https://example.com/image.png)
131+
</Streamdown>
132+
);
133+
const markdown = container.querySelector('[data-testid="markdown"]');
134+
expect(markdown).toBeTruthy();
135+
});
136+
137+
it("should accept multiple prefixes for allowedImagePrefixes", () => {
138+
const multiPrefixOptions = {
139+
allowedImagePrefixes: [
140+
"https://cdn.example.com",
141+
"https://images.example.com",
142+
"data:",
143+
],
144+
allowedLinkPrefixes: ["*"],
145+
};
146+
147+
const { container } = render(
148+
<Streamdown hardenOptions={multiPrefixOptions}>
149+
![Image](https://cdn.example.com/image.png)
150+
</Streamdown>
151+
);
152+
const markdown = container.querySelector('[data-testid="markdown"]');
153+
expect(markdown).toBeTruthy();
154+
});
155+
156+
it("should accept multiple prefixes for allowedLinkPrefixes", () => {
157+
const multiPrefixOptions = {
158+
allowedImagePrefixes: ["*"],
159+
allowedLinkPrefixes: [
160+
"https://example.com",
161+
"https://trusted.com",
162+
"mailto:",
163+
"tel:",
164+
],
165+
};
166+
167+
const { container } = render(
168+
<Streamdown hardenOptions={multiPrefixOptions}>
169+
[Link](https://example.com)
170+
</Streamdown>
171+
);
172+
const markdown = container.querySelector('[data-testid="markdown"]');
173+
expect(markdown).toBeTruthy();
174+
});
175+
176+
it("should accept defaultOrigin for relative URLs", () => {
177+
const originOptions = {
178+
allowedImagePrefixes: ["*"],
179+
allowedLinkPrefixes: ["*"],
180+
defaultOrigin: "https://example.com",
181+
};
182+
183+
const { container } = render(
184+
<Streamdown hardenOptions={originOptions}>
185+
[Relative Link](/path/to/page)
186+
</Streamdown>
187+
);
188+
const markdown = container.querySelector('[data-testid="markdown"]');
189+
expect(markdown).toBeTruthy();
190+
});
191+
192+
it("should work with hardenOptions and other props together", () => {
193+
const customOptions = {
194+
allowedImagePrefixes: ["https://"],
195+
allowedLinkPrefixes: ["https://", "mailto:"],
196+
defaultOrigin: "https://example.com",
197+
};
198+
199+
const { container } = render(
200+
<Streamdown
201+
hardenOptions={customOptions}
202+
className="custom-class"
203+
parseIncompleteMarkdown={true}
204+
>
205+
# Test Content
206+
</Streamdown>
207+
);
208+
209+
const wrapper = container.firstElementChild;
210+
expect(wrapper).toBeTruthy();
211+
expect(wrapper?.getAttribute("class")).toContain("custom-class");
212+
213+
const markdown = container.querySelector('[data-testid="markdown"]');
214+
expect(markdown).toBeTruthy();
215+
});
216+
217+
it("should handle hardenOptions with special protocol prefixes", () => {
218+
const specialProtocolOptions = {
219+
allowedImagePrefixes: ["data:", "blob:", "https://"],
220+
allowedLinkPrefixes: ["https://", "http://", "mailto:", "tel:", "#"],
221+
};
222+
223+
const { container } = render(
224+
<Streamdown hardenOptions={specialProtocolOptions}>
225+
![Data URI](data:image/png;base64,abc123)
226+
[Email](mailto:test@example.com)
227+
[Phone](tel:+1234567890)
228+
[Anchor](#section)
229+
</Streamdown>
230+
);
231+
const markdown = container.querySelector('[data-testid="markdown"]');
232+
expect(markdown).toBeTruthy();
233+
});
234+
});

packages/streamdown/__tests__/html-block-multiline.test.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@ import { render } from "@testing-library/react";
22
import { describe, expect, it, vi } from "vitest";
33
import { Streamdown } from "../index";
44

5-
// Mock the dependencies
6-
vi.mock("harden-react-markdown", () => ({
7-
default: (Component: any) => Component,
8-
}));
9-
105
describe("HTML Block Elements with Multiline Content - #164", () => {
116
it("should render multiline content inside details element", () => {
127
const content = `<details>

packages/streamdown/__tests__/streamdown.test.tsx

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,6 @@ vi.mock("react-markdown", () => ({
2727
},
2828
}));
2929

30-
vi.mock("harden-react-markdown", () => ({
31-
default: (Component: any) => Component,
32-
}));
33-
3430
vi.mock("rehype-katex", () => ({
3531
default: () => {},
3632
}));
@@ -88,32 +84,16 @@ describe("Streamdown Component", () => {
8884
expect(wrapper?.getAttribute("data-custom")).toBe("value");
8985
});
9086

91-
it("should use default allowed prefixes when not specified", () => {
92-
const { container } = render(<Streamdown>Content</Streamdown>);
93-
// These props are passed to child Block components, not to the wrapper div
94-
const markdown = container.querySelector('[data-testid="markdown"]');
95-
expect(markdown).toBeTruthy();
96-
});
97-
98-
it("should use custom allowed prefixes when specified", () => {
99-
const { container } = render(
100-
<Streamdown
101-
allowedImagePrefixes={["https://", "http://"]}
102-
allowedLinkPrefixes={["https://", "mailto:"]}
103-
>
104-
Content
105-
</Streamdown>
106-
);
107-
// These props are passed to child Block components, not to the wrapper div
108-
const markdown = container.querySelector('[data-testid="markdown"]');
109-
expect(markdown).toBeTruthy();
110-
});
87+
it("should accept custom hardenOptions", () => {
88+
const customOptions = {
89+
allowedImagePrefixes: ["https://"],
90+
allowedLinkPrefixes: ["https://", "mailto:"],
91+
defaultOrigin: "https://example.com",
92+
};
11193

112-
it("should pass defaultOrigin prop", () => {
11394
const { container } = render(
114-
<Streamdown defaultOrigin="https://example.com">Content</Streamdown>
95+
<Streamdown hardenOptions={customOptions}>Content</Streamdown>
11596
);
116-
// This prop is passed to child Block components, not to the wrapper div
11797
const markdown = container.querySelector('[data-testid="markdown"]');
11898
expect(markdown).toBeTruthy();
11999
});

0 commit comments

Comments
 (0)