Jump to…
snowinitial commitqoxwzsukwmkx1mo
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
11use std::sync::LazyLock;
12
13use ammonia::Builder;
14
15pub mod autolink;
16pub mod highlight;
17pub 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:`.
24static 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.
55pub 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.
79pub 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.
84pub 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)]
114mod 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("&lt;") || h.contains("&amp;"), "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