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 = {
render: (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
[Click me](javascript:alert('XSS')) <!-- Rendered as: <a href="#">Click me</a> -->
-
Data URI Attacks
</script>) <!-- Blocked — non-image data: URIs are replaced with # -->
quikdown includes built-in URL sanitization via sanitizeUrl(). All URLs in links and images are checked against a blocklist of dangerous protocols:
javascript:URLs are replaced with#vbscript:URLs are replaced with#data:URLs are replaced with#(exceptdata:image/*, which is allowed)
When you write a fence plugin, YOU are responsible for security:
// UNSAFE - Don't do this with untrusted input!
const unsafePlugin = {
render: (content, lang) => {
return content; // Returns raw, unescaped HTML
}
};
// SAFER - Validate and sanitize
const saferPlugin = {
render: (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 = {
render: (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
- Review URL sanitization settings (built-in
sanitizeUrl()blocksjavascript:,vbscript:, and non-imagedata:URIs by default)
If you discover a security vulnerability:
- DO NOT open a public issue
- Email security details to deftio@deftio.com
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
quikdown enforces automated security scanning in its build pipeline:
- ESLint + eslint-plugin-security runs on every build (
npm run buildstarts withnpm run lint) - The
security/detect-unsafe-regexandsecurity/detect-non-literal-regexprules are set to error level, meaning the build fails if a regex with catastrophic backtracking risk or a dynamicnew RegExp()is introduced - CI (GitHub Actions) runs the same pipeline, so security regressions block PRs
All line-classification logic (HR detection, fence tracking, block categorization) uses linear-scan functions instead of regex where nested quantifiers would be needed. These shared utilities live in src/quikdown_classify.js and are consumed by both the main parser and the editor, ensuring a single source of truth with zero backtracking risk.
For example, CommonMark HR detection (---, ***, _ _ _, etc.) uses an O(n) character scan rather than the traditional /^[-_*](\s*[-_*]){2,}\s*$/ pattern, which is vulnerable to catastrophic backtracking on adversarial input like "- " * 1000 + "x".
| Check | Status |
|---|---|
security/detect-unsafe-regex |
0 findings (error level) |
security/detect-non-literal-regexp |
0 findings (error level) |
security/detect-object-injection |
disabled (false positives on parser array iteration) |
All other eslint-plugin-security rules |
0 findings (warn level) |
When allow_unsafe_html is set to an array of tag names or an object, quikdown operates in whitelist mode:
- Listed tags pass through with their attributes intact
- All
on*event handler attributes are stripped - URL attributes (
href,src,action,formaction) are sanitized to blockjavascript:,vbscript:, and non-imagedata:URIs - Non-whitelisted tags are escaped as normal
Note: style attributes pass through on whitelisted tags. If your threat model requires blocking inline styles (e.g., CSS exfiltration or UI redress attacks), strip style attributes in a post-processing step or use a dedicated HTML sanitizer like DOMPurify after quikdown.
// Whitelist mode — style attributes pass through
quikdown('<div style="color:red">text</div>', {
allow_unsafe_html: ['div']
});
// → <div style="color:red">text</div>When the editor is in split or preview mode, the preview pane uses contentEditable="true". The rendered HTML from the bidirectional parser is inserted via innerHTML. This means:
- Fence plugin output is trusted — plugins return raw HTML that is inserted directly into the editable preview. Only use plugins you control.
- Built-in fence renderers (Mermaid, MathJax, SVG, HTML, GeoJSON, STL) load third-party scripts from CDNs. The editor marks these blocks as
contentEditable="false"to prevent editing, but the rendered content runs in the page context. data-qd-sourceattributes store the original fence source for roundtrip. The rich-copy handler sanitizes this content (stripping<script>tags andon*attributes) before processing.
Recommendation: Do not load untrusted markdown into the editor if fence rendering is enabled. Use enableComplexFences: false to disable all built-in renderers when editing untrusted content.
The customFences option maps language tags to render functions:
new QuikdownEditor(container, {
customFences: {
'chart': (code, lang) => renderChart(code)
}
});Custom fence functions receive raw fence content and return HTML that is inserted directly into the preview. There is no sanitization of custom fence output. The caller is responsible for escaping or sanitizing as needed. Treat custom fences the same as a fence plugin — only register renderers you trust.
Planned security improvements:
- 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
- Automated enforcement - Security lint at error level in CI
When in doubt, don't trust the input. The safest quikdown is one that never uses fence plugins with untrusted content.