quikdown is designed with security as a primary concern. This document explains our security model, design choices, and best practices for safe usage.
All HTML in markdown input is escaped, preventing XSS attacks:
const markdown = '<script>alert("XSS")</script> Hello **world**';
const html = quikdown(markdown);
// Output: <script>alert("XSS")</script> Hello <strong>world</strong>Unlike some markdown parsers, quikdown does not allow raw HTML to pass through by default. This is an intentional security decision.
Why?
- Prevents XSS attacks
- Eliminates stored XSS vulnerabilities
- Reduces security audit complexity
- Makes the parser safe for untrusted input
When you need to render trusted HTML, use the fence plugin system:
const trustedHtmlPlugin = (content, lang) => {
// Only allow HTML from explicitly marked blocks
if (lang === 'html-render' && isSourceTrusted()) {
return content; // Return raw HTML
}
return undefined; // Fall back to escaping
};
const html = quikdown(markdown, {
fence_plugin: trustedHtmlPlugin
});This approach makes trust explicit and granular.
The safest approach - all HTML is always escaped:
// Safe for any user input
const html = quikdown(untrustedMarkdown);Allow HTML only in specially marked fence blocks:
Regular text with <script>escaped HTML</script>
```html-render
<div class="custom-widget">
<!-- This HTML will be rendered if the plugin allows it -->
<button onclick="doSomething()">Click me</button>
</div>
```If you need inline HTML, sanitize server-side before parsing:
// Server-side
const sanitized = DOMPurify.sanitize(userInput);
const markdown = preprocessToMarkdown(sanitized);
const html = quikdown(markdown);-
Script Tag Injection
<script>alert('XSS')</script> <!-- Rendered as: <script>alert('XSS')</script> -->
-
Event Handler Injection
<img onerror="alert('XSS')" src="x"> <!-- Rendered as: <img onerror="alert('XSS')" src="x"> -->
-
JavaScript URLs (Future feature)
[Click me](javascript:alert('XSS')) <!-- Will be sanitized when URL validation is added -->
-
Data URI Attacks (Future feature)
</script>) <!-- Will be blocked when URL validation is added -->
Note: URL sanitization is not yet implemented. Currently, javascript: and data: URLs in links and images are NOT sanitized. This is on the roadmap.
When you write a fence plugin, YOU are responsible for security:
// UNSAFE - Don't do this with untrusted input!
const unsafePlugin = (content, lang) => {
return content; // Returns raw, unescaped HTML
};
// SAFER - Validate and sanitize
const saferPlugin = (content, lang) => {
if (lang === 'mermaid') {
// Mermaid handles its own escaping
return `<div class="mermaid">${escapeHtml(content)}</div>`;
}
return undefined;
};
// SAFEST - Use established libraries
const safestPlugin = (content, lang) => {
if (lang === 'html-preview') {
// Use DOMPurify or similar
return DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['div', 'span', 'p', 'a'],
ALLOWED_ATTR: ['class', 'href']
});
}
return undefined;
};- Validate language identifiers - Only handle expected languages
- Escape by default - When in doubt, escape HTML
- Use allowlists - Only allow known-safe constructs
- Sanitize output - Use libraries like DOMPurify
- Document trust requirements - Make it clear what input is expected
Use CSP headers to add defense-in-depth:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';">Note: unsafe-inline for styles is needed if using inline_styles: true.
// Safe for user-generated content
function renderComment(userMarkdown) {
return quikdown(userMarkdown, {
inline_styles: false // Use CSS classes
});
}// Admin users can embed widgets
function renderAdminContent(markdown, isAdmin) {
const options = {};
if (isAdmin) {
options.fence_plugin = {
render: (content, lang) => {
if (lang === 'widget') {
return renderWidget(JSON.parse(content));
}
}
};
}
return quikdown(markdown, options);
}// Different trust for different parts
function renderMixedContent(markdown, trustMap) {
return quikdown(markdown, {
fence_plugin: {
render: (content, lang) => {
const trust = trustMap[lang];
if (trust === 'full') {
return content; // Full trust
} else if (trust === 'sanitized') {
return DOMPurify.sanitize(content);
}
return undefined; // Default escaping
}
}
});
}Before deploying quikdown:
- Never pass untrusted HTML to fence plugins without sanitization
- Use CSP headers for defense-in-depth
- Validate plugin output if accepting third-party plugins
- Escape plugin errors - Don't display raw error messages
- Update regularly - Keep quikdown updated for security fixes
- Audit fence plugins - Review all custom plugin code
- Test with malicious input - Try XSS payloads in testing
- Use HTTPS - Prevent MITM attacks on delivered content
- Sanitize URLs (until built-in support is added)
If you discover a security vulnerability:
- DO NOT open a public issue
- Email security details to [security contact]
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
Planned security improvements:
- URL Sanitization - Block javascript:, data:, and other dangerous URLs
- Configurable URL Allowlist - Only allow specific URL schemes
- Plugin Sandboxing - Optional plugin output validation
- Security Headers Helper - Generate recommended CSP headers
- Built-in DOMPurify Integration - Optional HTML sanitization
quikdown's security model:
- Safe by default - No XSS without explicit opt-in
- Explicit trust - Trusted HTML requires fence plugins
- Granular control - Trust specific blocks, not everything
- Developer responsibility - Plugins must handle security
- Defense in depth - Use with CSP and sanitization
When in doubt, don't trust the input. The safest quikdown is one that never uses fence plugins with untrusted content.