Core Plugins

Security

Plugin for sanitizing markdown output by removing dangerous HTML elements and attributes, and restricting allowed URLs.

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.

parse.ts
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.

parse.ts
security({
  blockedTags: ['script', 'iframe', 'object', 'embed']
})
<p>Safe content</p>
<script>alert('XSS')</script>
<SCRIPT>alert('XSS')</SCRIPT>
<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')">

Dangerous Attribute Removal

Attributes that can be abused regardless of value are always stripped:

AttributeRisk
srcdocCan contain arbitrary HTML
formactionCan redirect form submissions
<iframe srcdoc="<script>alert('XSS')</script>"></iframe>
<button formaction="javascript:alert('XSS')">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/html
  • data:text/javascript
  • data:text/vbscript
  • data:text/css
  • data:text/plain
  • data: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')">

Safe protocols:

example.html
<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):

TagReason
objectCan embed arbitrary plugins
<object data="malicious.swf" type="application/x-shockwave-flash"></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:

TagRisk
scriptJavaScript execution
iframeLoads external content
objectEmbeds plugins / Flash
embedSimilar to object
linkLoads external stylesheets
styleCSS with javascript: expressions
baseChanges base URL for relative links
metaHTTP 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: ["*"]

The hard-coded unsafe protocol list (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

This option only affects src. data:text/* variants in href are always blocked by the hard-coded protocol list regardless of this setting.

Examples

User-Generated Content

api.ts
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
      })
    ]
  })
}
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

Article.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

Comment.tsx
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

parse.ts
// ✅ 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

server.ts
// ✅ 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

server.ts
// Express.js
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'none';"
  )
  next()
})

What Gets Sanitized

Always blocked

example.html
<!-- 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

example.html
<!-- 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 post phase, after parsing
  • Traverses the AST once — O(n) in the number of nodes
  • No impact on render time (runs at parse time)

See Also

Copyright © 2026