A security-focused rehype plugin that filters URLs based on allowed prefixes
npm install rehype-hardenA rehype plugin that ensures that untrusted markdown does not contain images from and links to unexpected origins.
This is particularly important for markdown returned from LLMs in AI agents which might have been subject to prompt
injection.
This package validates URL prefixes and URL origins. Prefix allow-lists can be circumvented
with open redirects, so make sure to make the prefixes are specific enough to avoid such attacks.
E.g. it is more secure to allow https://example.com/images/ than it is to allow all ofhttps://example.com/ which may contain open redirects.
Additionally, URLs may contain path traversal like /../. This package does not resolve these.
It is your responsibility that your web server does not allow such traversal.
- 🔒 URL Filtering: Blocks links and images that don't match allowed URL prefixes
- 🔧 Drop-in: Works with any rehype-compatible pipeline
``bash`
npm install rehype-hardenor
yarn add rehype-hardenor
pnpm add rehype-harden
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remarkRehype";
import { unified } from "unified";
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
defaultOrigin: "https://mysite.com",
allowedLinkPrefixes: ["https://github.com/", "https://docs."],
allowedImagePrefixes: ["https://via.placeholder.com", "/"],
})
.use(/ whatever compiler you want, eg hast-to-jsx-runtime or hast-to-svelte /);
`
#### defaultOrigin?: string
- The origin to resolve relative URLs against
- Required when allowedLinkPrefixes or allowedImagePrefixes are provided (except when using wildcard ["*"])["*"]
- When using wildcard without defaultOrigin, relative URLs (e.g., /path, ./page) are allowed and preserved as-is"https://mysite.com"
- Example:
#### allowedLinkPrefixes?: string[]
- Array of URL prefixes that are allowed for links
- Links not matching these prefixes will be blocked and shown as [blocked]"*"
- Use to allow all URLs (disables filtering. However, javascript: and data: URLs are always disallowed)[]
- Default: (blocks all links)['https://github.com/', 'https://docs.example.com/']
- Example: or ['*']
#### allowedImagePrefixes?: string[]
- Array of URL prefixes that are allowed for images
- Images not matching these prefixes will be blocked and shown as placeholders
- Use "*" to allow all URLs (disables filtering. However, javascript: and data: URLs are always disallowed unless allowDataImages is enabled)[]
- Default: (blocks all images)['https://via.placeholder.com/', '/']
- Example: or ['*']
#### allowDataImages?: boolean
- When set to true, allows data:image/* URLs (base64-encoded images) in image sourcesdata:image/*
- This is useful for scenarios where images are embedded directly in markdown (e.g., documents converted from PDF or .docx)
- Only URLs are allowed; other data: URLs (like data:text/html) remain blocked for securitydata:
- URLs are never allowed in links, regardless of this settingfalse
- Default: (blocks all data: URLs)true
- Example:
#### allowedProtocols?: string[]
- Array of custom URL protocols that are allowed in links
- Useful for deep links to applications (e.g., tel:, mailto:, postman:, vscode:, slack:)"*"
- Use to allow all protocols that can be parsed as valid URLsjavascript:
- Dangerous protocols (, data:, file:, vbscript:) are always blocked regardless of this setting[]
- Default: (only allows built-in safe protocols: https:, http:, mailto:, irc:, ircs:, xmpp:, blob:)['tel:', 'postman:', 'vscode:']
- Example: or ['*']
#### blockedImageClass?: string
- When an image is blocked, by default it is rendered as a span with the text [Image blocked: {alt text (if provided)}]. blockedImageClass will be added as a class to this span to allow styling.
#### blockedLinkClass?: string
- Same as above, but for blocked links.
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
// Blocks all external links and images by default
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden) // No options = blocks everything
.use(/ your compiler /);
const result = processor.processSync(markdownContent);
`
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
defaultOrigin: "https://mysite.com",
allowedLinkPrefixes: [
"https://github.com/",
"https://docs.github.com/",
"https://www.npmjs.com/",
],
allowedImagePrefixes: [
"https://via.placeholder.com/",
"https://images.unsplash.com/",
"/", // Allow relative images
],
})
.use(/ your compiler /);
const result = processor.processSync(markdownContent);
`
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
defaultOrigin: "https://mysite.com",
allowedLinkPrefixes: ["https://mysite.com/"],
allowedImagePrefixes: ["https://mysite.com/"],
})
.use(/ your compiler /);
const markdownWithRelativeUrls =
Relative Link
!Relative Image;
const result = processor.processSync(markdownWithRelativeUrls);
`
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
allowedLinkPrefixes: ["*"],
allowedImagePrefixes: ["*"],
})
.use(/ your compiler /);
const markdownWithExternalUrls =
Any Link
!Any Image
Relative Link;
const result = processor.processSync(markdownWithExternalUrls);
// All URLs are allowed, including relative URLs like /internal-page
`
Note: Using "*" disables URL filtering entirely. Only use this when you trust the markdown source. When using wildcard without defaultOrigin, relative URLs are preserved as-is in the output.
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
defaultOrigin: "https://mysite.com",
allowedImagePrefixes: ["https://mysite.com/"],
allowDataImages: true, // Enable base64 images
})
.use(/ your compiler /);
const markdownWithBase64Images =
!Base64 Image
!Regular Image;
const result = processor.processSync(markdownWithBase64Images);
`
Note: This is particularly useful when converting documents from formats like PDF or .docx where images are embedded as base64. Only data:image/* URLs are allowed; other data: URLs remain blocked for security.
Blob URLs (blob:) are automatically allowed by default for both links and images. These are browser-generated URLs that reference in-memory objects and are commonly used for:
- Previewing user-uploaded files before upload
- Client-side image manipulation
- Displaying generated content
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
defaultOrigin: "https://mysite.com",
allowedImagePrefixes: ["https://mysite.com/"],
})
.use(/ your compiler /);
const markdownWithBlobUrl =
!Preview;
const result = processor.processSync(markdownWithBlobUrl);
// The blob: URL will be allowed even without being in allowedImagePrefixes
`
Note: Blob URLs are safe because they can only reference content already loaded in the browser's memory. They cannot be used to exfiltrate data or load external resources.
Enable custom protocols for deep linking to applications and services:
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
allowedProtocols: ['tel:', 'mailto:', 'postman:', 'vscode:', 'slack:'],
})
.use(/ your compiler /);
const markdownWithCustomProtocols =
Call us
Email support
Open in Postman
View in VS Code
Join Slack;
const result = processor.processSync(markdownWithCustomProtocols);
// All these custom protocol links will be allowed
`
Common use cases:
- tel: - Phone number links that open the dialer on mobile devices
- mailto: - Email links (allowed by default, but shown here for completeness)
- sms: - SMS/text message links
- postman:, vscode:, slack: - Deep links to desktop applications
- Custom app protocols - Links to your own Electron or native applications
You can also use the wildcard to allow any custom protocol:
`ts`
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
allowedProtocols: ['*'], // Allow all protocols
})
.use(/ your compiler /);
Security Note: Even with allowedProtocols: ['*'], dangerous protocols like javascript:, data:, file:, and vbscript: are always blocked for security. Custom protocols are safe because they trigger OS-level protocol handlers and don't execute in the browser context.
`ts
import { harden } from "rehype-harden";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
const processor = unified()
.use(remarkParse)
.use(remarkRehype)
.use(harden, {
defaultOrigin: "https://mysite.com",
allowedLinkPrefixes: ["https://trusted.com/"],
allowedImagePrefixes: ["https://trusted.com/"],
blockedLinkClass: "blocked-link",
blockedImageClass: "blocked-image",
})
.use(/ your compiler /);
const result = processor.processSync(markdownContent);
`
- Blocked Links: Rendered as plain text with [blocked] indicator
- Blocked Images: Rendered as placeholder text with image description
- User Feedback: Clear indication when content has been blocked for security
- XSS Prevention: Blocks javascript:, data:, vbscript:, file: and other dangerous protocols (always, regardless of configuration)https:
- Redirect Protection: Prevents unauthorized redirects to malicious sites
- Tracking Prevention: Blocks unauthorized image tracking pixels
- Domain Spoofing: Validates full URLs, not just domains
- Safe Protocols: Allows safe protocols including , http:, mailto:, blob:, and others while blocking dangerous onestel:
- Custom Protocols: Optional support for custom protocols (e.g., , postman:, vscode:) with explicit opt-in via allowedProtocols
The package includes comprehensive tests covering:
- Basic markdown rendering
- URL filtering for links and images
- Relative URL handling
- Security bypass prevention
- Edge cases and malformed URLs
- TypeScript type safety
Run tests:
`bash`
pnpm test
1. Fork the repository
2. Create your feature branch (git checkout -b feature/amazing-feature)git commit -m 'Add some amazing feature'
3. Commit your changes ()git push origin feature/amazing-feature`)
4. Push to the branch (
5. Open a Pull Request
MIT License - see the LICENSE file for details.
If you discover a security vulnerability, please send an e-mail to