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)
}
use axum::{
extract::{Path, State},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use std::sync::Arc;
use crate::markdown::render_markdown;
use crate::repo::DocRepo;
pub struct AppState {
pub repo: Arc<dyn DocRepo>,
}
pub enum ApiError {
NotFound,
Internal,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "document not found").into_response(),
ApiError::Internal => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
}
}
}
}
pub async fn render_doc(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Html<String>, ApiError> {
let doc = state
.repo
.fetch(&id)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)?;
let safe = render_markdown(&doc.body);
Ok(Html(safe.into_inner()))
}
use async_trait::async_trait;
pub struct Document {
pub id: String,
pub body: String,
}
#[async_trait]
pub trait DocRepo: Send + Sync {
async fn fetch(&self, id: &str) -> Result<Option<Document>, sqlx::Error>;
}
pub struct PgDocRepo {
pub pool: sqlx::PgPool,
}
#[async_trait]
impl DocRepo for PgDocRepo {
async fn fetch(&self, id: &str) -> Result<Option<Document>, sqlx::Error> {
let row = sqlx::query_as!(
Document,
"SELECT id, body FROM documents WHERE id = $1",
id
)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
}
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
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
struct Config<'a> {
name: &'a str,
value: &'a str,
}
fn parse_config(line: &str) -> Config {
Lifetime annotations for flexible borrowing in structs
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
Share this code
Here's the card — post it anywhere.