rust 111 lines · 3 tabs

Rendering Untrusted Markdown to Sanitized HTML with pulldown-cmark and ammonia

Shared by codesnips Aug 2026
3 tabs
use pulldown_cmark::{html, Options, Parser};
use std::collections::HashSet;

pub struct SafeHtml(String);

impl SafeHtml {
    pub fn into_inner(self) -> String {
        self.0
    }
}

pub fn render_markdown(source: &str) -> SafeHtml {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_TASKLISTS);
    options.insert(Options::ENABLE_FOOTNOTES);

    let parser = Parser::new_ext(source, options);
    let mut unsafe_html = String::new();
    html::push_html(&mut unsafe_html, parser);

    sanitize(&unsafe_html)
}

fn sanitize(dirty: &str) -> SafeHtml {
    let schemes: HashSet<&str> = ["http", "https", "mailto"].iter().copied().collect();

    let cleaned = ammonia::Builder::default()
        .link_rel(Some("noopener noreferrer"))
        .url_schemes(schemes)
        .add_generic_attributes(&["class"])
        .clean(dirty)
        .to_string();

    SafeHtml(cleaned)
}
3 files · rust Explain with highlit

This snippet shows the two-stage pipeline commonly used to turn user-supplied Markdown into HTML that is safe to embed in a page. The core insight is that pulldown-cmark is a parser, not a sanitizer: it faithfully translates Markdown into HTML, including any raw <script> or onerror attributes an attacker embedded in the source. Rendering that output directly is a classic stored-XSS hole, so a dedicated sanitizer must run over the generated HTML before it reaches a browser.

In markdown.rs, render_markdown configures the parser with Options, enabling GitHub-flavored extensions like tables, strikethrough, and task lists. The Parser is an iterator of Events, which push_html collects into a String. Crucially, that string is treated as untrusted and passed straight into sanitize, which builds an ammonia::Builder with an explicit allow-list. Ammonia works by whitelisting rather than blacklisting — only the listed tags and attributes survive, everything else is stripped. The builder also calls link_rel to force noopener noreferrer on links and url_schemes to forbid javascript: URLs, closing two common bypass vectors. The function returns SafeHtml, a newtype wrapper that makes the type system remember this string has been through sanitization.

The SafeHtml newtype is a small but deliberate design choice: because the render function is the only place that constructs it, any value of that type is guaranteed sanitized, so downstream code cannot accidentally emit raw HTML.

In handler.rs, the Axum handler render_doc fetches a document by id through a repository, maps a missing row to 404, and otherwise calls render_markdown. The result is wrapped in Html, which sets the correct Content-Type. Note that SafeHtml unwraps into a plain String via into_inner only at the response boundary. Fetch failures collapse into a 500 through the ? operator and an IntoResponse error type.

The main trade-off is that whitelisting can strip legitimate but unusual markup, so the allow-list must be tuned to the product. A pitfall worth noting: sanitizing the Markdown source instead of the rendered HTML does not work, because Markdown constructs can produce dangerous HTML that isn't visible in the source. Sanitizing after rendering is the reliable ordering.


Related snips

Share this code

Here's the card — post it anywhere.

Rendering Untrusted Markdown to Sanitized HTML with pulldown-cmark and ammonia — share card
Link copied