| 1 | //! `df-render` — markdown and content rendering. | |
| 2 | //! | |
| 3 | //! Spec §8: "Markdown via `comrak` with GFM extensions, rendered server-side | |
| 4 | //! and sanitized with `ammonia` on a strict allowlist. No raw HTML passthrough." | |
| 5 | //! | |
| 6 | //! Repository content is attacker-controlled: anyone who can push can put | |
| 7 | //! anything in a README. Every path out of this module is sanitised, and the | |
| 8 | //! sanitiser is applied *after* rendering, so no markdown construct can smuggle | |
| 9 | //! markup past it. | |
| 10 | ||
| 11 | use std::sync::LazyLock; | |
| 12 | ||
| 13 | use ammonia::Builder; | |
| 14 | ||
| 15 | pub mod autolink; | |
| 16 | pub mod highlight; | |
| 17 | pub mod symbols; | |
| 18 | ||
| 19 | /// The sanitiser, built once. | |
| 20 | /// | |
| 21 | /// Deliberately strict. `ammonia`'s defaults already strip `<script>`, but the | |
| 22 | /// dangerous surface is wider than that: `style` attributes enable | |
| 23 | /// clickjacking overlays, and unrestricted URL schemes enable `javascript:`. | |
| 24 | static CLEANER: LazyLock<Builder<'static>> = LazyLock::new(|| { | |
| 25 | let mut b = Builder::default(); | |
| 26 | ||
| 27 | // Only http/https/mailto. This is what stops `javascript:` and `data:` | |
| 28 | // URLs in links and images. | |
| 29 | b.url_schemes(["http", "https", "mailto"].into_iter().collect()); | |
| 30 | ||
| 31 | // Anything with a target must not be able to reach back via window.opener. | |
| 32 | b.link_rel(Some("noopener noreferrer nofollow")); | |
| 33 | ||
| 34 | // No inline styles, no id (which can override page anchors and break | |
| 35 | // fragment navigation), no event handlers of any kind. | |
| 36 | b.generic_attributes(["title"].into_iter().collect()); | |
| 37 | ||
| 38 | // Task-list checkboxes. Allowing `<input>` is safe *only* because | |
| 39 | // `render.unsafe_ = false` below makes comrak escape raw HTML from the | |
| 40 | // source — so the only `<input>` that ever reaches the sanitiser is the | |
| 41 | // disabled checkbox comrak itself emits for `- [x]`. A user cannot inject | |
| 42 | // one; `raw_input_tags_are_still_escaped` pins that. | |
| 43 | b.add_tags(["input"]); | |
| 44 | b.add_tag_attributes("input", ["type", "checked", "disabled"]); | |
| 45 | b.attribute_filter(|element, attribute, value| match (element, attribute) { | |
| 46 | // Defence in depth: even from comrak, accept only checkboxes. | |
| 47 | ("input", "type") if value != "checkbox" => None, | |
| 48 | _ => Some(value.into()), | |
| 49 | }); | |
| 50 | ||
| 51 | b | |
| 52 | }); | |
| 53 | ||
| 54 | /// Render GitHub-flavoured markdown to sanitised HTML. | |
| 55 | pub fn markdown_to_html(source: &str) -> String { | |
| 56 | let mut options = comrak::Options::default(); | |
| 57 | ||
| 58 | options.extension.strikethrough = true; | |
| 59 | options.extension.table = true; | |
| 60 | options.extension.autolink = true; | |
| 61 | options.extension.tasklist = true; | |
| 62 | options.extension.footnotes = true; | |
| 63 | ||
| 64 | // Belt and braces with the sanitiser below: comrak is told not to emit raw | |
| 65 | // HTML at all, and ammonia then strips anything that slips through. | |
| 66 | options.render.unsafe_ = false; | |
| 67 | options.render.escape = false; | |
| 68 | options.render.hardbreaks = false; | |
| 69 | ||
| 70 | let rendered = comrak::markdown_to_html(source, &options); | |
| 71 | CLEANER.clean(&rendered).to_string() | |
| 72 | } | |
| 73 | ||
| 74 | /// Render markdown for a comment body. | |
| 75 | /// | |
| 76 | /// Same pipeline as README rendering; kept as a separate entry point so the | |
| 77 | /// two can diverge (comments will gain `#123` and `@handle` autolinking in M3) | |
| 78 | /// without loosening README rendering. | |
| 79 | pub fn comment_to_html(source: &str) -> String { | |
| 80 | markdown_to_html(source) | |
| 81 | } | |
| 82 | ||
| 83 | /// Strip markdown to a plain-text excerpt, for list views and page titles. | |
| 84 | pub fn excerpt(source: &str, max_chars: usize) -> String { | |
| 85 | let mut out = String::with_capacity(max_chars.min(source.len())); | |
| 86 | let mut chars = 0; | |
| 87 | ||
| 88 | for line in source.lines() { | |
| 89 | let line = line.trim(); | |
| 90 | // Skip headings, fences and blockquote markers; we want prose. | |
| 91 | if line.is_empty() || line.starts_with('#') || line.starts_with("```") { | |
| 92 | continue; | |
| 93 | } | |
| 94 | for c in line.chars() { | |
| 95 | if chars >= max_chars { | |
| 96 | out.push('…'); | |
| 97 | return out; | |
| 98 | } | |
| 99 | // Collapse markdown emphasis characters rather than showing them. | |
| 100 | if matches!(c, '*' | '_' | '`' | '[' | ']') { | |
| 101 | continue; | |
| 102 | } | |
| 103 | out.push(c); | |
| 104 | chars += 1; | |
| 105 | } | |
| 106 | out.push(' '); | |
| 107 | chars += 1; | |
| 108 | } | |
| 109 | ||
| 110 | out.trim().to_string() | |
| 111 | } | |
| 112 | ||
| 113 | #[cfg(test)] | |
| 114 | mod tests { | |
| 115 | use super::*; | |
| 116 | ||
| 117 | #[test] | |
| 118 | fn renders_basic_markdown() { | |
| 119 | let h = markdown_to_html("# Title\n\nSome **bold** text."); | |
| 120 | assert!(h.contains("<h1>")); | |
| 121 | assert!(h.contains("<strong>bold</strong>")); | |
| 122 | } | |
| 123 | ||
| 124 | #[test] | |
| 125 | fn renders_gfm_tables_and_tasklists() { | |
| 126 | let h = markdown_to_html("| a | b |\n|---|---|\n| 1 | 2 |"); | |
| 127 | assert!(h.contains("<table>"), "GFM tables must render: {h}"); | |
| 128 | ||
| 129 | // The checkbox carries the done/todo distinction. Stripping it renders | |
| 130 | // both items identically, silently losing the meaning. | |
| 131 | let h = markdown_to_html("- [x] done\n- [ ] todo"); | |
| 132 | assert!(h.contains("checkbox"), "tasklists must render: {h}"); | |
| 133 | assert!(h.contains("checked"), "completed items must stay marked: {h}"); | |
| 134 | } | |
| 135 | ||
| 136 | #[test] | |
| 137 | fn raw_input_tags_are_still_escaped() { | |
| 138 | // The assumption that makes allowing <input> safe: raw HTML in the | |
| 139 | // source never reaches the sanitiser as markup. | |
| 140 | let h = markdown_to_html("<input type=\"text\" name=\"password\">"); | |
| 141 | assert!( | |
| 142 | !h.contains("<input"), | |
| 143 | "a user-authored <input> must not survive: {h}" | |
| 144 | ); | |
| 145 | ||
| 146 | let h = markdown_to_html("<input type=\"checkbox\" onclick=\"alert(1)\">"); | |
| 147 | assert!(!h.contains("onclick"), "handler survived: {h}"); | |
| 148 | assert!(!h.contains("<input"), "user-authored input survived: {h}"); | |
| 149 | } | |
| 150 | ||
| 151 | // ─── the sanitiser (spec §9) ───────────────────────────────────────────── | |
| 152 | ||
| 153 | #[test] | |
| 154 | fn strips_script_tags() { | |
| 155 | let h = markdown_to_html("<script>alert('xss')</script>"); | |
| 156 | assert!(!h.contains("<script"), "script survived: {h}"); | |
| 157 | assert!(!h.contains("alert"), "script body survived: {h}"); | |
| 158 | } | |
| 159 | ||
| 160 | #[test] | |
| 161 | fn strips_javascript_urls() { | |
| 162 | // The classic README XSS. | |
| 163 | let h = markdown_to_html("[click me](javascript:alert(1))"); | |
| 164 | assert!(!h.contains("javascript:"), "javascript: URL survived: {h}"); | |
| 165 | } | |
| 166 | ||
| 167 | #[test] | |
| 168 | fn strips_event_handlers() { | |
| 169 | let h = markdown_to_html("<img src=x onerror=\"alert(1)\">"); | |
| 170 | assert!(!h.contains("onerror"), "event handler survived: {h}"); | |
| 171 | } | |
| 172 | ||
| 173 | #[test] | |
| 174 | fn strips_inline_styles() { | |
| 175 | // Inline styles enable invisible full-page overlays. | |
| 176 | let h = markdown_to_html("<div style=\"position:fixed;inset:0\">x</div>"); | |
| 177 | assert!(!h.contains("style="), "inline style survived: {h}"); | |
| 178 | } | |
| 179 | ||
| 180 | #[test] | |
| 181 | fn strips_data_urls() { | |
| 182 | let h = markdown_to_html("[x](data:text/html;base64,PHNjcmlwdD4=)"); | |
| 183 | assert!(!h.contains("data:text/html"), "data: URL survived: {h}"); | |
| 184 | } | |
| 185 | ||
| 186 | #[test] | |
| 187 | fn strips_iframes_and_objects() { | |
| 188 | for src in [ | |
| 189 | "<iframe src=\"https://evil.example\"></iframe>", | |
| 190 | "<object data=\"x\"></object>", | |
| 191 | "<embed src=\"x\">", | |
| 192 | "<form action=\"https://evil.example\"><input name=p></form>", | |
| 193 | ] { | |
| 194 | let h = markdown_to_html(src); | |
| 195 | for tag in ["<iframe", "<object", "<embed", "<form"] { | |
| 196 | assert!(!h.contains(tag), "{tag} survived from {src:?}: {h}"); | |
| 197 | } | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | #[test] | |
| 202 | fn keeps_ordinary_links_but_adds_rel() { | |
| 203 | let h = markdown_to_html("[jj](https://github.com/jj-vcs/jj)"); | |
| 204 | assert!(h.contains("https://github.com/jj-vcs/jj")); | |
| 205 | assert!(h.contains("noopener"), "rel must be set: {h}"); | |
| 206 | } | |
| 207 | ||
| 208 | #[test] | |
| 209 | fn html_entities_in_text_are_escaped_not_executed() { | |
| 210 | let h = markdown_to_html("5 < 6 & 7 > 2"); | |
| 211 | assert!(h.contains("<") || h.contains("&"), "got {h}"); | |
| 212 | } | |
| 213 | ||
| 214 | #[test] | |
| 215 | fn a_malicious_readme_cannot_break_out_of_a_code_fence() { | |
| 216 | let h = markdown_to_html("```\n</code></pre><script>alert(1)</script>\n```"); | |
| 217 | assert!(!h.contains("<script"), "escaped the fence: {h}"); | |
| 218 | } | |
| 219 | ||
| 220 | #[test] | |
| 221 | fn survives_pathological_input_without_panicking() { | |
| 222 | // Deeply nested emphasis and lists are a known parser stressor. | |
| 223 | let nasty = "*".repeat(5000); | |
| 224 | let _ = markdown_to_html(&nasty); | |
| 225 | let nested = "> ".repeat(1000) + "text"; | |
| 226 | let _ = markdown_to_html(&nested); | |
| 227 | let _ = markdown_to_html(&"[".repeat(2000)); | |
| 228 | } | |
| 229 | ||
| 230 | // ─── excerpt ───────────────────────────────────────────────────────────── | |
| 231 | ||
| 232 | #[test] | |
| 233 | fn excerpt_skips_headings_and_strips_emphasis() { | |
| 234 | let e = excerpt("# Heading\n\nSome **bold** prose here.", 100); | |
| 235 | assert!(!e.contains('#')); | |
| 236 | assert!(!e.contains('*')); | |
| 237 | assert!(e.contains("Some bold prose")); | |
| 238 | } | |
| 239 | ||
| 240 | #[test] | |
| 241 | fn excerpt_truncates_with_an_ellipsis() { | |
| 242 | let e = excerpt("a very long line of prose that keeps going", 10); | |
| 243 | assert!(e.chars().count() <= 11, "got {e:?}"); | |
| 244 | assert!(e.ends_with('…')); | |
| 245 | } | |
| 246 | ||
| 247 | #[test] | |
| 248 | fn excerpt_of_empty_input_is_empty() { | |
| 249 | assert_eq!(excerpt("", 50), ""); | |
| 250 | assert_eq!(excerpt("# only a heading", 50), ""); | |
| 251 | } | |
| 252 | } |
252 lines · Rust