Security

Sanitize the parsed AST by removing dangerous elements, blocking malicious protocols, and restricting link destinations.

The comark/plugins/security plugin sanitizes the parsed AST, removing dangerous HTML elements, blocking malicious protocols, and restricting allowed link destinations.

Usage

import { parseMarkdown } from 'comark'
import security from 'comark/plugins/security'

const result = await parseMarkdown(content, {
  plugins: [security()]
})

With framework components:

<script setup lang="ts">
import { Markdown } from '@comark/vue'
import security from '@comark/vue/plugins/security'

const plugins = [
  security({
    blockedTags: ['script', 'iframe'],
    allowedProtocols: ['https', 'mailto']
  })
]
</script>

<template>
  <Markdown :plugins="plugins">{{ content }}</Markdown>
</template>

Features

Several sanitizations are applied automatically and cannot be disabled:

Event handlers

All on* attributes are stripped regardless of case: onclick, onerror, onload, onmouseover, and any other on* attribute.

<div onclick="alert('XSS')">Click me</div>
<img src="x" onerror="alert('XSS')">

Dangerous attributes

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

AttributeRisk
srcdocCan contain arbitrary HTML
formactionCan redirect form submissions
innerHTMLInjects raw HTML through framework renderers
dangerouslySetInnerHTMLInjects raw HTML through framework renderers
textContentOverwrites an element's children
Framework renderers (Vue, React, Svelte, Angular) never forward innerHTML, dangerouslySetInnerHTML, or textContent from document attributes, even without this plugin. Raw HTML has its own explicit path through the default html plugin.

Protocol blocking

href and src values are decoded (URL-encoded and HTML entity variants included) and checked against a hard-coded block list. These protocols 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

The same check applies to :href and :src bindings twice: on the JSON-decoded value at parse time, and again on the resolved value at render time, so bindings cannot smuggle an unsafe URL through frontmatter or other data sources.

<a href="javascript:alert('XSS')">Click</a>
<img src="data:text/html,<script>alert('XSS')</script>">

API

security(options?)

Returns a ComarkPlugin that sanitizes the parsed AST.

Parameters:

  • options? - Optional configuration, see Options

Returns: ComarkPlugin


Options

OptionTypeDefaultDescription
blockedTagsstring[][]Tag names to remove entirely from the AST
allowedTagsstring[][]Tag names to allow exclusively in the AST
tagFallbackfunctionundefinedDefines how to handle unallowed tags in the AST
allowedProtocolsstring[]['*']Protocols permitted in href and src
allowedLinkPrefixesstring[]['*']URL prefixes permitted in href
allowedImagePrefixesstring[]['*']URL prefixes permitted in src
defaultOriginstringundefinedRewrite disallowed URLs to this origin instead of stripping
allowDataImagesbooleantrueAllow data:image/* URIs in src

blockedTags

Tag names to completely remove from the AST. Matching is case-insensitive, so SCRIPT, Script, and script are all caught.

security({
  blockedTags: ['script', 'iframe', 'object', 'embed', 'link', 'style']
})
TagRisk
scriptJavaScript execution
iframeLoads external content
objectEmbeds plugins or Flash
embedSimilar to object
linkLoads external stylesheets
styleCSS with javascript: expressions
baseChanges base URL for relative links
metaHTTP refresh / redirect

allowedTags

Tag names to exclusively keep in the AST. Matching is case-insensitive, so, so SPAN, Span, and span are all caught.

security({
  allowedTags: ['p', 'span', 'ul', 'li', 'ol', 'strong']
})

The as prop (which makes framework renderers resolve a different component than the element's own tag) is held to the same filters: an as value naming a blocked or not-allowed tag is stripped, and the element falls back to its own tag.

tagFallback

Defines the replacement strategy for tags that are filtered out because they are not present in the allowedTags (whitelist) or present in the blockedTags (blacklist).

import { textContent } from 'comark/utils'

security({
  allowedTags: ['p', 'span'],
  tagFallback: (element: ElementNode) => {
    // Remove all tags and return the text content
    return textContent(element)
  }
})

allowedProtocols

Restricts which URL protocols are permitted in href and src attributes. Use ['*'] to allow all protocols not already on the hard-coded block list.

security({
  allowedProtocols: ['https', 'mailto']
})
The hard-coded unsafe protocols (javascript:, vbscript:, data:text/*) are a floor that cannot be overridden. Even allowedProtocols: ['javascript'] will not unblock javascript: URLs.

allowedLinkPrefixes

Restricts which URLs are allowed in href attributes. Relative URLs (starting with /, #, etc.) are always allowed regardless of this setting.

Prefixes compare by parsed origin plus a path-segment boundary, not by raw string matching: https://myapp.com allows https://myapp.com/docs but never a lookalike host such as https://myapp.com.evil.com. Scheme-relative URLs (//evil.com/page) resolve to an absolute URL and go through the same checks.

When a URL does not match any prefix and defaultOrigin is set, the URL is rewritten instead of stripped.

security({
  allowedLinkPrefixes: ['https://myapp.com', 'https://docs.myapp.com']
})

allowedImagePrefixes

Same as allowedLinkPrefixes but applies to src attributes only. The two options are checked independently; restricting one does not affect the other.

security({
  allowedImagePrefixes: ['https://cdn.myapp.com']
})

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

allowDataImages

Controls whether data:image/* URIs are allowed in src attributes. Set to false to block base64-encoded images, which can be used as tracking pixels or embedded payloads.

security({
  allowDataImages: false
})
data:text/* variants in href are always blocked by the hard-coded protocol list regardless of this setting.

Examples

User-generated content

The most common use case: lock down everything that could execute code or phone home:

import { parseMarkdown } from 'comark'
import security from 'comark/plugins/security'

const result = await parseMarkdown(userInput, {
  plugins: [
    security({
      blockedTags: ['script', 'iframe', 'object', 'embed', 'link', 'style'],
      allowedProtocols: ['https', 'mailto'],
      allowDataImages: false
    })
  ]
})

Keep all links and images within your own infrastructure, rewriting external URLs instead of stripping them:

security({
  allowedLinkPrefixes: ['https://myapp.com', 'https://docs.myapp.com'],
  allowedImagePrefixes: ['https://cdn.myapp.com'],
  defaultOrigin: 'https://myapp.com'
})

Block external images

Prevent tracking pixels and externally-hosted images while keeping everything else permissive:

security({
  allowedImagePrefixes: ['https://cdn.myapp.com'],
  allowDataImages: false
})

Recommendations

Block tags, not just attributes

Blocking only <script> may not be enough: <iframe>, <object>, <embed>, <link>, and <style> can also execute or load external content:

// ✅ More thorough
security({
  blockedTags: ['script', 'iframe', 'object', 'embed', 'link', 'style']
})

// ⚠️ Incomplete
security({
  blockedTags: ['script']
})

Sanitize before storage

Sanitizing at parse time on read means malicious content already made it into the database. Sanitize before writing instead:

// ✅ Sanitize before storing
async function saveArticle(content: string) {
  const sanitized = await parseMarkdown(content, {
    plugins: [security({ blockedTags: ['script', 'iframe'] })]
  })
  await db.articles.create({ content: sanitized })
}

Pair with a Content Security Policy

The plugin sanitizes the AST, but a CSP header adds a second line of defense in the browser:

// Express.js
res.setHeader(
  'Content-Security-Policy',
  "default-src 'self'; script-src 'none';"
)
The plugin runs during the post phase and traverses the AST once (O(n) in the number of nodes), with no impact on render time.