| 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