Standardize on CommonMark parsing with an HTML allowlist
Use CommonMark parsing, fenced code blocks, and an HTML allowlist to prevent Markdown rendering drift and XSS from raw HTML.
30 Sept 2025, 21:34 UTC

The problem: rendering drift and raw HTML in Markdown
Teams often choose a Markdown parser because it is popular, not because its output is predictable. One renderer converts a fenced code block into highlighted HTML; another escapes the same block as plain text. A third accepts raw <script> or onload attributes and passes them to the browser. That inconsistency creates broken documentation and a cross-site scripting (XSS) risk whenever authors include inline HTML.
CommonMark is a formal specification for Markdown with a reference implementation, designed so the same input produces the same HTML across compliant parsers. The practical takeaway is to standardize on CommonMark parsing, require fenced code blocks with an info string, and run rendered output through an HTML sanitizer that allows only a small set of elements.
The decision and constraints
Decision: adopt a CommonMark-compliant parser as the house standard, enable fenced code blocks, and sanitize rendered HTML with an allowlist.
Constraints:
- Identical Markdown must produce identical HTML across editors, previewers, and publishing targets.
- Authors must be able to label code-block languages without relying on indentation.
- Raw HTML from untrusted authors must not execute in the browser.
- Documents should remain readable outside GitHub-specific renderers.
Supported options compared
| Option | Specification status | Code-block handling | Tables and task lists | Raw HTML | Portability risk |
|---|---|---|---|---|---|
| CommonMark | Formal spec with reference implementation | Fenced blocks with an info string | Not part of the core spec | Allowed unless sanitized | Low |
| GitHub Flavored Markdown (GFM) | GitHub-maintained superset of CommonMark | Fenced blocks with an info string | Supported | Allowed unless sanitized | Medium outside GitHub |
| Markdown Extra | Parser-specific extensions | Fenced and indented blocks | Supported | Allowed unless sanitized | High |
An info string is the text immediately after the opening backticks of a fenced block, typically used to declare the programming language. Fenced blocks are preferable to indented blocks because the language is explicit and rendering does not depend on whitespace counting.
Trade-offs
- CommonMark. Most predictable rendering, but core CommonMark does not define tables or task lists. If your team needs those, you must add extensions or accept that some Markdown will not render.
- GFM. Convenient tables, task lists, and autolinks, but GFM is not a formal specification and can behave differently outside GitHub. Task-list checkboxes and GFM tables may not appear in a strict CommonMark renderer.
- Markdown Extra. Footnotes, definition lists, and element attributes are useful, but they are parser-specific extensions. Documents using them often break portability when you switch libraries.
- Raw HTML. Most Markdown parsers pass raw HTML through by default. Allowlisting is safer than blocklisting, but you must still strip dangerous attributes such as onclick, onload, and style.
Concrete implementation: sanitized rendering pipeline
This example uses a CommonMark parser and an HTML sanitizer. Adjust the import paths and API calls to match the library versions in your project.
import { Parser, HtmlRenderer } from 'commonmark';
import sanitizeHtml from 'sanitize-html';
const parser = new Parser();
const renderer = new HtmlRenderer();
const ALLOWED_TAGS = [
'p', 'em', 'strong', 'code', 'pre', 'blockquote',
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
];
function renderMarkdown(markdown) {
const ast = parser.parse(markdown);
const html = renderer.render(ast);
return sanitizeHtml(html, {
allowedTags: ALLOWED_TAGS,
allowedAttributes: {},
disallowedTagsMode: 'discard'
});
}
Run the install command in your project root:
npm install --save commonmark sanitize-html
Where to run: terminal in the repository root. Permissions: a normal user account with write access to package.json and the dependency directory. Placeholder: replace library names if your stack uses a different CommonMark implementation. Expected check: after installation, the dependency manifest lists pinned versions for both packages. Risk: adding dependencies changes the lockfile; review the diff before committing, and confirm the sanitizer version still supports the allowedTags option.
Validation
- Create a sample document containing a fenced code block with an info string, a small table if you use GFM extensions, inline HTML such as <span>, and an SVG element with an onload attribute.
- Render the document with the pipeline above and inspect the output. The HTML should include <pre><code for the fenced block and should not contain <script>, <iframe>, or any on* attribute.
- Run the same Markdown through a second CommonMark-compliant library and compare the HTML for headings, lists, and fenced code blocks. The output should match for those constructs.
- As a quick regression check, search the rendered output for dangerous patterns:
grep -E '<(script|iframe)|on[a-z]+=' output.html
A clean check returns no matches.
Limitations
- Core CommonMark does not include tables or task lists. If you require them, enable GFM extensions and verify rendering outside GitHub before adopting.
- The HTML allowlist must be maintained. Adding a new permitted element requires a security review of its attributes.
- CommonMark implementations can differ in edge cases before full spec conformance. Pin parser versions in your dependency manifest and re-run the comparison test after upgrades.
Practical way to confirm the result
Render the sample document, then open the output HTML and verify that code blocks carry language information, headings and lists match the CommonMark reference structure, and the SVG onload attribute is either removed or the element is discarded entirely. If any disallowed tag or attribute appears, tighten the sanitizer configuration before deploying.
Rollback
Because this change adds dependencies and modifies the package manifest, revert package.json and the lockfile to their previous state and reinstall dependencies. No Markdown content is altered by this change, so existing source files require no migration.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.