Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! `df-render` — markdown and content rendering.
Matt W2//!
Matt W3//! Spec §8: "Markdown via `comrak` with GFM extensions, rendered server-side
Matt W4//! and sanitized with `ammonia` on a strict allowlist. No raw HTML passthrough."
Matt W5//!
Matt W6//! Repository content is attacker-controlled: anyone who can push can put
Matt W7//! anything in a README. Every path out of this module is sanitised, and the
Matt W8//! sanitiser is applied *after* rendering, so no markdown construct can smuggle
Matt W9//! markup past it.
Matt W10
Matt W11use std::sync::LazyLock;
Matt W12
Matt W13use ammonia::Builder;
Matt W14
Matt W15pub mod autolink;
Matt W16pub mod highlight;
Matt W17pub mod symbols;
Matt W18
Matt W19/// The sanitiser, built once.
Matt W20///
Matt W21/// Deliberately strict. `ammonia`'s defaults already strip `<script>`, but the
Matt W22/// dangerous surface is wider than that: `style` attributes enable
Matt W23/// clickjacking overlays, and unrestricted URL schemes enable `javascript:`.
Matt W24static CLEANER: LazyLock<Builder<'static>> = LazyLock::new(|| {
Matt W25 let mut b = Builder::default();
Matt W26
Matt W27 // Only http/https/mailto. This is what stops `javascript:` and `data:`
Matt W28 // URLs in links and images.
Matt W29 b.url_schemes(["http", "https", "mailto"].into_iter().collect());
Matt W30
Matt W31 // Anything with a target must not be able to reach back via window.opener.
Matt W32 b.link_rel(Some("noopener noreferrer nofollow"));
Matt W33
Matt W34 // No inline styles, no id (which can override page anchors and break
Matt W35 // fragment navigation), no event handlers of any kind.
Matt W36 b.generic_attributes(["title"].into_iter().collect());
Matt W37
Matt W38 // Task-list checkboxes. Allowing `<input>` is safe *only* because
Matt W39 // `render.unsafe_ = false` below makes comrak escape raw HTML from the
Matt W40 // source — so the only `<input>` that ever reaches the sanitiser is the
Matt W41 // disabled checkbox comrak itself emits for `- [x]`. A user cannot inject
Matt W42 // one; `raw_input_tags_are_still_escaped` pins that.
Matt W43 b.add_tags(["input"]);
Matt W44 b.add_tag_attributes("input", ["type", "checked", "disabled"]);
Matt W45 b.attribute_filter(|element, attribute, value| match (element, attribute) {
Matt W46 // Defence in depth: even from comrak, accept only checkboxes.
Matt W47 ("input", "type") if value != "checkbox" => None,
Matt W48 _ => Some(value.into()),
Matt W49 });
Matt W50
Matt W51 b
Matt W52});
Matt W53
Matt W54/// Render GitHub-flavoured markdown to sanitised HTML.
Matt W55pub fn markdown_to_html(source: &str) -> String {
Matt W56 let mut options = comrak::Options::default();
Matt W57
Matt W58 options.extension.strikethrough = true;
Matt W59 options.extension.table = true;
Matt W60 options.extension.autolink = true;
Matt W61 options.extension.tasklist = true;
Matt W62 options.extension.footnotes = true;
Matt W63
Matt W64 // Belt and braces with the sanitiser below: comrak is told not to emit raw
Matt W65 // HTML at all, and ammonia then strips anything that slips through.
Matt W66 options.render.unsafe_ = false;
Matt W67 options.render.escape = false;
Matt W68 options.render.hardbreaks = false;
Matt W69
Matt W70 let rendered = comrak::markdown_to_html(source, &options);
Matt W71 CLEANER.clean(&rendered).to_string()
Matt W72}
Matt W73
Matt W74/// Render markdown for a comment body.
Matt W75///
Matt W76/// Same pipeline as README rendering; kept as a separate entry point so the
Matt W77/// two can diverge (comments will gain `#123` and `@handle` autolinking in M3)
Matt W78/// without loosening README rendering.
Matt W79pub fn comment_to_html(source: &str) -> String {
Matt W80 markdown_to_html(source)
Matt W81}
Matt W82
Matt W83/// Strip markdown to a plain-text excerpt, for list views and page titles.
Matt W84pub fn excerpt(source: &str, max_chars: usize) -> String {
Matt W85 let mut out = String::with_capacity(max_chars.min(source.len()));
Matt W86 let mut chars = 0;
Matt W87
Matt W88 for line in source.lines() {
Matt W89 let line = line.trim();
Matt W90 // Skip headings, fences and blockquote markers; we want prose.
Matt W91 if line.is_empty() || line.starts_with('#') || line.starts_with("```") {
Matt W92 continue;
Matt W93 }
Matt W94 for c in line.chars() {
Matt W95 if chars >= max_chars {
Matt W96 out.push('…');
Matt W97 return out;
Matt W98 }
Matt W99 // Collapse markdown emphasis characters rather than showing them.
Matt W100 if matches!(c, '*' | '_' | '`' | '[' | ']') {
Matt W101 continue;
Matt W102 }
Matt W103 out.push(c);
Matt W104 chars += 1;
Matt W105 }
Matt W106 out.push(' ');
Matt W107 chars += 1;
Matt W108 }
Matt W109
Matt W110 out.trim().to_string()
Matt W111}
Matt W112
Matt W113#[cfg(test)]
Matt W114mod tests {
Matt W115 use super::*;
Matt W116
Matt W117 #[test]
Matt W118 fn renders_basic_markdown() {
Matt W119 let h = markdown_to_html("# Title\n\nSome **bold** text.");
Matt W120 assert!(h.contains("<h1>"));
Matt W121 assert!(h.contains("<strong>bold</strong>"));
Matt W122 }
Matt W123
Matt W124 #[test]
Matt W125 fn renders_gfm_tables_and_tasklists() {
Matt W126 let h = markdown_to_html("| a | b |\n|---|---|\n| 1 | 2 |");
Matt W127 assert!(h.contains("<table>"), "GFM tables must render: {h}");
Matt W128
Matt W129 // The checkbox carries the done/todo distinction. Stripping it renders
Matt W130 // both items identically, silently losing the meaning.
Matt W131 let h = markdown_to_html("- [x] done\n- [ ] todo");
Matt W132 assert!(h.contains("checkbox"), "tasklists must render: {h}");
Matt W133 assert!(h.contains("checked"), "completed items must stay marked: {h}");
Matt W134 }
Matt W135
Matt W136 #[test]
Matt W137 fn raw_input_tags_are_still_escaped() {
Matt W138 // The assumption that makes allowing <input> safe: raw HTML in the
Matt W139 // source never reaches the sanitiser as markup.
Matt W140 let h = markdown_to_html("<input type=\"text\" name=\"password\">");
Matt W141 assert!(
Matt W142 !h.contains("<input"),
Matt W143 "a user-authored <input> must not survive: {h}"
Matt W144 );
Matt W145
Matt W146 let h = markdown_to_html("<input type=\"checkbox\" onclick=\"alert(1)\">");
Matt W147 assert!(!h.contains("onclick"), "handler survived: {h}");
Matt W148 assert!(!h.contains("<input"), "user-authored input survived: {h}");
Matt W149 }
Matt W150
Matt W151 // ─── the sanitiser (spec §9) ─────────────────────────────────────────────
Matt W152
Matt W153 #[test]
Matt W154 fn strips_script_tags() {
Matt W155 let h = markdown_to_html("<script>alert('xss')</script>");
Matt W156 assert!(!h.contains("<script"), "script survived: {h}");
Matt W157 assert!(!h.contains("alert"), "script body survived: {h}");
Matt W158 }
Matt W159
Matt W160 #[test]
Matt W161 fn strips_javascript_urls() {
Matt W162 // The classic README XSS.
Matt W163 let h = markdown_to_html("[click me](javascript:alert(1))");
Matt W164 assert!(!h.contains("javascript:"), "javascript: URL survived: {h}");
Matt W165 }
Matt W166
Matt W167 #[test]
Matt W168 fn strips_event_handlers() {
Matt W169 let h = markdown_to_html("<img src=x onerror=\"alert(1)\">");
Matt W170 assert!(!h.contains("onerror"), "event handler survived: {h}");
Matt W171 }
Matt W172
Matt W173 #[test]
Matt W174 fn strips_inline_styles() {
Matt W175 // Inline styles enable invisible full-page overlays.
Matt W176 let h = markdown_to_html("<div style=\"position:fixed;inset:0\">x</div>");
Matt W177 assert!(!h.contains("style="), "inline style survived: {h}");
Matt W178 }
Matt W179
Matt W180 #[test]
Matt W181 fn strips_data_urls() {
Matt W182 let h = markdown_to_html("[x](data:text/html;base64,PHNjcmlwdD4=)");
Matt W183 assert!(!h.contains("data:text/html"), "data: URL survived: {h}");
Matt W184 }
Matt W185
Matt W186 #[test]
Matt W187 fn strips_iframes_and_objects() {
Matt W188 for src in [
Matt W189 "<iframe src=\"https://evil.example\"></iframe>",
Matt W190 "<object data=\"x\"></object>",
Matt W191 "<embed src=\"x\">",
Matt W192 "<form action=\"https://evil.example\"><input name=p></form>",
Matt W193 ] {
Matt W194 let h = markdown_to_html(src);
Matt W195 for tag in ["<iframe", "<object", "<embed", "<form"] {
Matt W196 assert!(!h.contains(tag), "{tag} survived from {src:?}: {h}");
Matt W197 }
Matt W198 }
Matt W199 }
Matt W200
Matt W201 #[test]
Matt W202 fn keeps_ordinary_links_but_adds_rel() {
Matt W203 let h = markdown_to_html("[jj](https://github.com/jj-vcs/jj)");
Matt W204 assert!(h.contains("https://github.com/jj-vcs/jj"));
Matt W205 assert!(h.contains("noopener"), "rel must be set: {h}");
Matt W206 }
Matt W207
Matt W208 #[test]
Matt W209 fn html_entities_in_text_are_escaped_not_executed() {
Matt W210 let h = markdown_to_html("5 < 6 & 7 > 2");
Matt W211 assert!(h.contains("&lt;") || h.contains("&amp;"), "got {h}");
Matt W212 }
Matt W213
Matt W214 #[test]
Matt W215 fn a_malicious_readme_cannot_break_out_of_a_code_fence() {
Matt W216 let h = markdown_to_html("```\n</code></pre><script>alert(1)</script>\n```");
Matt W217 assert!(!h.contains("<script"), "escaped the fence: {h}");
Matt W218 }
Matt W219
Matt W220 #[test]
Matt W221 fn survives_pathological_input_without_panicking() {
Matt W222 // Deeply nested emphasis and lists are a known parser stressor.
Matt W223 let nasty = "*".repeat(5000);
Matt W224 let _ = markdown_to_html(&nasty);
Matt W225 let nested = "> ".repeat(1000) + "text";
Matt W226 let _ = markdown_to_html(&nested);
Matt W227 let _ = markdown_to_html(&"[".repeat(2000));
Matt W228 }
Matt W229
Matt W230 // ─── excerpt ─────────────────────────────────────────────────────────────
Matt W231
Matt W232 #[test]
Matt W233 fn excerpt_skips_headings_and_strips_emphasis() {
Matt W234 let e = excerpt("# Heading\n\nSome **bold** prose here.", 100);
Matt W235 assert!(!e.contains('#'));
Matt W236 assert!(!e.contains('*'));
Matt W237 assert!(e.contains("Some bold prose"));
Matt W238 }
Matt W239
Matt W240 #[test]
Matt W241 fn excerpt_truncates_with_an_ellipsis() {
Matt W242 let e = excerpt("a very long line of prose that keeps going", 10);
Matt W243 assert!(e.chars().count() <= 11, "got {e:?}");
Matt W244 assert!(e.ends_with('…'));
Matt W245 }
Matt W246
Matt W247 #[test]
Matt W248 fn excerpt_of_empty_input_is_empty() {
Matt W249 assert_eq!(excerpt("", 50), "");
Matt W250 assert_eq!(excerpt("# only a heading", 50), "");
Matt W251 }
Matt W252}

252 lines · Rust