Security
The comark/plugins/security plugin provides automatic security sanitization for parsed markdown content. It removes dangerous HTML elements, validates attributes to prevent XSS attacks, and lets you restrict which URLs and protocols are permitted.
Basic Usage
The security plugin is included in the comark core package and available under comark/plugins/security.
import { parse } from 'comark'
import security from 'comark/plugins/security'
const result = await parse(content, {
plugins: [
security({
blockedTags: ['script', 'iframe']
})
]
})
Security Features
Tag Blocking
Remove dangerous HTML elements entirely with blockedTags. Tag matching is case-insensitive, so SCRIPT, Script, and script are all caught.
security({
blockedTags: ['script', 'iframe', 'object', 'embed']
})
<p>Safe content</p>
<script>alert('XSS')</script>
<SCRIPT>alert('XSS')</SCRIPT>
<p>More content</p>
<p>Safe content</p>
<p>More content</p>
Event Handler Removal
All on* event handlers are automatically removed, regardless of case.
Blocked attributes: onclick, onload, onerror, onmouseover, and all other on* attributes.
<div onclick="alert('XSS')">Click me</div>
<img src="x" onerror="alert('XSS')">
<div>Click me</div>
<img src="x">
Dangerous Attribute Removal
Attributes that can be abused regardless of value are always stripped:
| Attribute | Risk |
|---|---|
srcdoc | Can contain arbitrary HTML |
formaction | Can redirect form submissions |
<iframe srcdoc="<script>alert('XSS')</script>"></iframe>
<button formaction="javascript:alert('XSS')">Submit</button>
<iframe></iframe>
<button>Submit</button>
Protocol Validation
href and src values are parsed and checked against a hard-coded block list. These are always blocked, even if allowedProtocols: ['*'] is set:
javascript:vbscript:data:text/htmldata:text/javascriptdata:text/vbscriptdata:text/cssdata:text/plaindata:text/xml
URL-encoded variants (e.g. javascript%3A) and HTML entity variants are decoded before checking.
<a href="javascript:alert('XSS')">Click</a>
<a href="data:text/html,<script>alert('XSS')</script>">Click</a>
<img src="javascript:alert('XSS')">
<a>Click</a>
<a>Click</a>
<img>
Safe protocols:
<a href="https://example.com">Safe link</a>
<a href="/relative/path">Safe link</a>
<img src="data:image/png;base64,...">
Unsafe Tag Attribute Stripping
Certain tags have all their attributes stripped (matching is case-insensitive):
| Tag | Reason |
|---|---|
object | Can embed arbitrary plugins |
<object data="malicious.swf" type="application/x-shockwave-flash"></object>
<object></object>
Options
The security plugin has the following default options:
security({
blockedTags: [], // No tags blocked by default
allowedProtocols: ['*'], // All safe protocols allowed
allowedLinkPrefixes: ['*'], // All link destinations allowed
allowedImagePrefixes: ['*'], // All image sources allowed
defaultOrigin: undefined, // Strip disallowed URLs (no rewriting)
allowDataImages: true, // data:image/* src allowed
})
blockedTags
Array of tag names to completely remove from the output tree. Matching is case-insensitive.
security({
blockedTags: ['script', 'iframe', 'object', 'embed', 'link', 'style']
})
Default: []
Common tags to block:
| Tag | Risk |
|---|---|
script | JavaScript execution |
iframe | Loads external content |
object | Embeds plugins / Flash |
embed | Similar to object |
link | Loads external stylesheets |
style | CSS with javascript: expressions |
base | Changes base URL for relative links |
meta | HTTP refresh / redirect |
allowedProtocols
Restrict which URL protocols are permitted in href and src attributes. Use ["*"] (default) to allow all protocols that are not already on the hard-coded block list.
// Only allow https and mailto links
security({
allowedProtocols: ['https', 'mailto']
})
Default: ["*"]
javascript:, vbscript:, data:text/*) is a floor that cannot be overridden — even allowedProtocols: ['javascript'] will not unblock javascript: URLs.allowedLinkPrefixes
Restrict which URLs are allowed in href attributes. Use ["*"] (default) to allow all. Relative URLs (starting with /, #, etc.) are always allowed regardless of this setting.
When a URL does not match any prefix and defaultOrigin is set, the URL is rewritten instead of stripped.
// Only allow links to your own domain
security({
allowedLinkPrefixes: ['https://myapp.com', 'https://docs.myapp.com']
})
Default: ["*"]
allowedImagePrefixes
Same as allowedLinkPrefixes but applies to src attributes only. allowedLinkPrefixes and allowedImagePrefixes are checked independently — restricting one does not affect the other.
// Only allow images from your CDN
security({
allowedImagePrefixes: ['https://cdn.myapp.com']
})
Default: ["*"]
defaultOrigin
When a URL fails the allowedLinkPrefixes or allowedImagePrefixes check, it is rewritten to use this origin instead of being stripped. The path, query, and fragment of the original URL are preserved.
security({
allowedLinkPrefixes: ['https://myapp.com'],
defaultOrigin: 'https://myapp.com'
})
// https://evil.com/path → https://myapp.com/path
Default: undefined (strip disallowed URLs)
allowDataImages
Controls whether data: URIs are allowed in src attributes. Set to false to block base64-encoded images, which can be used for tracking pixels or embedded payloads.
security({
allowDataImages: false
})
Default: true
src. data:text/* variants in href are always blocked by the hard-coded protocol list regardless of this setting.Examples
User-Generated Content
import { parse } from 'comark'
import security from 'comark/plugins/security'
async function sanitizeUserContent(markdown: string) {
return await parse(markdown, {
plugins: [
security({
blockedTags: ['script', 'iframe', 'object', 'embed', 'link', 'style'],
allowedProtocols: ['https', 'mailto'],
allowDataImages: false
})
]
})
}
Restrict Links to Your Domain
security({
blockedTags: ['script', 'iframe'],
allowedLinkPrefixes: ['https://myapp.com', 'https://docs.myapp.com'],
allowedImagePrefixes: ['https://cdn.myapp.com'],
defaultOrigin: 'https://myapp.com' // rewrite external links instead of stripping
})
Lock Down Images (no external tracking)
security({
allowedImagePrefixes: ['https://cdn.myapp.com'],
allowDataImages: false
})
Blog Comments with Vue
<script setup lang="ts">
import { Comark } from '@comark/vue'
import security from 'comark/plugins/security'
const plugins = [
security({
blockedTags: ['script', 'iframe', 'object', 'embed'],
allowedProtocols: ['https', 'mailto'],
allowDataImages: false
})
]
</script>
<template>
<article class="prose">
<Comark :plugins="plugins">{{ content }}</Comark>
</article>
</template>
Blog Comments with React
import { Comark } from '@comark/react'
import security from 'comark/plugins/security'
interface CommentProps {
content: string
author: string
}
export default function Comment({ content, author }: CommentProps) {
const plugins = [
security({
blockedTags: ['script', 'iframe', 'object', 'embed'],
allowedProtocols: ['https', 'mailto'],
allowDataImages: false
})
]
return (
<div className="comment">
<strong>{author}</strong>
<Comark plugins={plugins}>{content}</Comark>
</div>
)
}
Security Best Practices
Always sanitize user-generated content
// ✅ Good
const result = await parse(userInput, {
plugins: [
security({ blockedTags: ['script', 'iframe'] }),
]
})
// ❌ Never trust user input without sanitization
const result = await parse(userInput)
Block tags, not just attributes
// ✅ More thorough
security({
blockedTags: ['script', 'iframe', 'object', 'embed', 'link', 'style']
})
// ⚠️ Blocking only script may not be enough
security({
blockedTags: ['script']
})
Sanitize before storage
// ✅ Sanitize before storing — malicious content never reaches the DB
async function saveArticle(content: string) {
const sanitized = await parse(content, {
plugins: [security({ blockedTags: ['script'] })]
})
await db.articles.create({ content: sanitized })
}
// ❌ Sanitizing on read means malicious content is already in the DB
Pair with Content Security Policy
// Express.js
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'none';"
)
next()
})
What Gets Sanitized
Always blocked
<!-- Event handlers -->
<div onclick="..."> → <div>
<img onerror="..."> → <img>
<body onload="..."> → <body>
<!-- Dangerous protocols -->
<a href="javascript:..."> → <a>
<a href="data:text/html,..."> → <a>
<iframe src="vbscript:..."> → <iframe>
<!-- Dangerous attributes -->
<iframe srcdoc="..."> → <iframe>
<button formaction="..."> → <button>
Safe by default
<!-- Standard elements -->
<p>, <div>, <span>, <a>, <img>, <h1>–<h6>, <ul>, <ol>, <li>
<!-- Safe protocols -->
https://, http://, mailto:, tel:, /relative, #anchor
<!-- Safe attributes -->
id, class, style, title, alt, src (validated), href (validated)
<!-- Image data URLs (unless allowDataImages: false) -->
<img src="data:image/png;base64,...">
<img src="data:image/svg+xml,...">
Performance
The security plugin:
- Runs during the
postphase, after parsing - Traverses the AST once — O(n) in the number of nodes
- No impact on render time (runs at parse time)
See Also
- Summary Plugin - Extract content summaries
- TOC Plugin - Generate table of contents
- Emoji Plugin - Convert emoji shortcodes
- Parse API - Parsing markdown content