Skip to main content

max / makenotwork

15.8 KB · 484 lines History Blame Raw
1 //! HTML sanitization for custom pages, built on [`ammonia`].
2 //!
3 //! The policy is an explicit allowlist: structural
4 //! and text elements plus media, no scripting, no embeds, no forms, no inline
5 //! `style` attribute (all CSS goes in the dedicated CSS field, for a single
6 //! sanitization path and better caching). Every URL-bearing attribute is routed
7 //! through [`resolve_internal_url`], so a custom page can reference only MNW.
8 //!
9 //! Anything outside the allowlist is dropped by ammonia. Dropped *URLs* are
10 //! additionally recorded as [`Rejection`]s for the editor's blocked-references
11 //! panel, the primary teaching surface for the closed-system rule.
12
13 use std::borrow::Cow;
14 use std::collections::{HashMap, HashSet};
15 use std::sync::{Arc, Mutex};
16
17 use super::Rejection;
18 use super::url_filter::{UrlPolicy, resolve_internal_url, resolve_srcset};
19
20 /// Allowed element names.
21 const ALLOWED_TAGS: &[&str] = &[
22 "a",
23 "abbr",
24 "article",
25 "aside",
26 "b",
27 "blockquote",
28 "br",
29 "caption",
30 "cite",
31 "code",
32 "col",
33 "colgroup",
34 "dd",
35 "details",
36 "div",
37 "dl",
38 "dt",
39 "em",
40 "figcaption",
41 "figure",
42 "footer",
43 "h1",
44 "h2",
45 "h3",
46 "h4",
47 "h5",
48 "h6",
49 "header",
50 "hr",
51 "i",
52 "img",
53 "kbd",
54 "li",
55 "main",
56 "mark",
57 "nav",
58 "ol",
59 "p",
60 "picture",
61 "pre",
62 "q",
63 "s",
64 "samp",
65 "section",
66 "small",
67 "source",
68 "span",
69 "strong",
70 "sub",
71 "summary",
72 "sup",
73 "table",
74 "tbody",
75 "td",
76 "tfoot",
77 "th",
78 "thead",
79 "time",
80 "tr",
81 "u",
82 "ul",
83 "video",
84 "audio",
85 "track",
86 ];
87
88 /// Attributes allowed on any element.
89 const GENERIC_ATTRS: &[&str] = &["class", "id", "title", "lang", "dir"];
90
91 /// Tags whose entire content is discarded (not unwrapped) when the tag is
92 /// stripped, script/style bodies and metadata must never resurface as text.
93 const CLEAN_CONTENT_TAGS: &[&str] = &[
94 "script", "style", "iframe", "object", "embed", "noscript", "template", "svg", "math", "frame",
95 "frameset", "head", "title", "base", "meta", "link", "applet", "param", "canvas", "form",
96 "input", "button", "select", "textarea",
97 ];
98
99 /// Per-tag attribute allowlist. URL-bearing attributes here are still validated
100 /// by the attribute filter; listing them only makes them *eligible*.
101 fn tag_attributes() -> HashMap<&'static str, HashSet<&'static str>> {
102 let set = |attrs: &[&'static str]| attrs.iter().copied().collect::<HashSet<_>>();
103 HashMap::from([
104 ("a", set(&["href"])),
105 (
106 "img",
107 set(&["src", "alt", "width", "height", "loading", "srcset"]),
108 ),
109 (
110 "source",
111 set(&["src", "srcset", "type", "media", "width", "height"]),
112 ),
113 // autoplay dropped; looping silent video allowed (decision #4).
114 (
115 "video",
116 set(&[
117 "src", "controls", "loop", "muted", "poster", "preload", "width", "height",
118 ]),
119 ),
120 // loop/muted/autoplay dropped, no surprise / looping audio (decision #4).
121 ("audio", set(&["src", "controls", "preload"])),
122 (
123 "track",
124 set(&["src", "kind", "srclang", "label", "default"]),
125 ),
126 ("time", set(&["datetime"])),
127 ("th", set(&["colspan", "rowspan", "scope"])),
128 ("td", set(&["colspan", "rowspan", "scope"])),
129 ("col", set(&["span"])),
130 ("colgroup", set(&["span"])),
131 ("details", set(&["open"])),
132 ("ol", set(&["start", "reversed"])),
133 ])
134 }
135
136 /// Which attributes carry URLs, and how to parse them.
137 enum UrlAttr {
138 Single,
139 SrcSet,
140 }
141
142 fn url_attribute(element: &str, attribute: &str) -> Option<UrlAttr> {
143 match (element, attribute) {
144 ("a", "href") => Some(UrlAttr::Single),
145 ("img" | "source" | "video" | "audio" | "track", "src") => Some(UrlAttr::Single),
146 ("video", "poster") => Some(UrlAttr::Single),
147 ("img" | "source", "srcset") => Some(UrlAttr::SrcSet),
148 _ => None,
149 }
150 }
151
152 /// Sanitize user HTML. Returns the cleaned markup plus every URL the sanitizer
153 /// stripped (for the blocked-references panel). The output references only MNW
154 /// and contains no scripting, embeds, forms, or inline styles.
155 pub fn sanitize_html(input: &str, policy: &UrlPolicy) -> (String, Vec<Rejection>) {
156 let rejections: Arc<Mutex<Vec<Rejection>>> = Arc::new(Mutex::new(Vec::new()));
157
158 let tags: HashSet<&str> = ALLOWED_TAGS.iter().copied().collect();
159 let generic: HashSet<&str> = GENERIC_ATTRS.iter().copied().collect();
160 let clean_content: HashSet<&str> = CLEAN_CONTENT_TAGS.iter().copied().collect();
161 let per_tag = tag_attributes();
162
163 let filter_policy = policy.clone();
164 let filter_sink = Arc::clone(&rejections);
165
166 let mut builder = ammonia::Builder::default();
167 builder
168 .tags(tags)
169 .generic_attributes(generic)
170 .tag_attributes(per_tag)
171 .clean_content_tags(clean_content)
172 // Let candidate schemes through ammonia's built-in check so our
173 // attribute filter is the single authority. It rejects everything
174 // that does not resolve to on-platform https, and recording happens
175 // there (ammonia's own scheme drop is silent). Safe because the filter
176 // covers every URL-bearing attribute on every allowed tag.
177 .url_schemes(HashSet::from([
178 "https",
179 "http",
180 "data",
181 "javascript",
182 "mailto",
183 "ftp",
184 "blob",
185 "file",
186 "vbscript",
187 ]))
188 .url_relative(ammonia::UrlRelative::PassThrough)
189 // User-authored anchors never influence ranking and are flagged as
190 // user-generated content (decision: per-page link rel).
191 .link_rel(Some("nofollow ugc"))
192 .strip_comments(true)
193 .attribute_filter(move |element, attribute, value| {
194 match url_attribute(element, attribute) {
195 None => Some(Cow::Borrowed(value)),
196 Some(kind) => {
197 let location = format!("{element} {attribute}");
198 let result = match kind {
199 UrlAttr::Single => resolve_internal_url(value, &filter_policy, &location),
200 UrlAttr::SrcSet => resolve_srcset(value, &filter_policy, &location),
201 };
202 match result {
203 Ok(v) => Some(Cow::Owned(v)),
204 Err(rej) => {
205 filter_sink
206 .lock()
207 .expect("rejection sink poisoned")
208 .push(rej);
209 None
210 }
211 }
212 }
213 }
214 });
215
216 let cleaned = builder.clean(input).to_string();
217 let collected = std::mem::take(&mut *rejections.lock().expect("rejection sink poisoned"));
218 (cleaned, collected)
219 }
220
221 #[cfg(test)]
222 mod tests {
223 use super::super::RejectionKind;
224 use super::*;
225
226 fn policy() -> UrlPolicy {
227 UrlPolicy::new(
228 "https://u.makenot.work/alice/proj",
229 [
230 "makenot.work".to_string(),
231 "u.makenot.work".to_string(),
232 "cdn.makenot.work".to_string(),
233 ],
234 )
235 .unwrap()
236 }
237
238 fn clean(html: &str) -> String {
239 sanitize_html(html, &policy()).0
240 }
241
242 #[test]
243 fn keeps_allowed_structure() {
244 let out =
245 clean("<section><h1 class=\"t\">Hi</h1><p>Hello <strong>world</strong></p></section>");
246 assert!(out.contains("<section>"));
247 assert!(out.contains("<h1 class=\"t\">"));
248 assert!(out.contains("<strong>world</strong>"));
249 }
250
251 #[test]
252 fn strips_script_and_its_content() {
253 let out = clean("<p>ok</p><script>alert(1)</script>");
254 assert!(out.contains("ok"));
255 assert!(!out.contains("alert"));
256 assert!(!out.contains("<script"));
257 }
258
259 #[test]
260 fn strips_style_tag_and_content() {
261 let out = clean("<style>body{display:none}</style><p>hi</p>");
262 assert!(!out.to_lowercase().contains("display"));
263 assert!(out.contains("hi"));
264 }
265
266 #[test]
267 fn strips_inline_style_attribute() {
268 let out = clean("<p style=\"color:red\">x</p>");
269 assert!(!out.contains("style"));
270 assert!(out.contains("<p>x</p>"));
271 }
272
273 #[test]
274 fn strips_event_handlers() {
275 let out = clean("<div onclick=\"steal()\">x</div>");
276 assert!(!out.to_lowercase().contains("onclick"));
277 assert!(!out.contains("steal"));
278 }
279
280 /// Seal for the hand-maintained `url_attribute` allowlist. The sanitizer's
281 /// safety rests on the documented invariant that the attribute filter covers
282 /// *every* URL-bearing attribute on *every* allowed tag, an uncovered one
283 /// would let a `javascript:` URL through ammonia (which we deliberately let
284 /// candidate schemes past, so our filter is the sole authority). This fails
285 /// the build if the allowlist (`ALLOWED_TAGS` × `GENERIC_ATTRS` ∪ per-tag)
286 /// ever permits a known URL-bearing attribute that `url_attribute` doesn't
287 /// recognise, keeping the two in sync by construction.
288 #[test]
289 fn url_attribute_covers_every_allowed_url_bearing_attribute() {
290 // The canonical set of HTML attributes that carry a URL. If any of these
291 // becomes allowed on a tag without `url_attribute` covering it, a
292 // javascript: payload would survive sanitization.
293 const URL_BEARING: &[&str] = &[
294 "href",
295 "src",
296 "srcset",
297 "poster",
298 "action",
299 "formaction",
300 "cite",
301 "data",
302 "background",
303 "longdesc",
304 "usemap",
305 "ping",
306 "manifest",
307 "codebase",
308 "archive",
309 "xlink:href",
310 ];
311 let generic: HashSet<&str> = GENERIC_ATTRS.iter().copied().collect();
312 let per_tag = tag_attributes();
313 let mut gaps = Vec::new();
314 for &tag in ALLOWED_TAGS {
315 let mut allowed: HashSet<&str> = generic.clone();
316 if let Some(attrs) = per_tag.get(tag) {
317 allowed.extend(attrs.iter().copied());
318 }
319 for &attr in &allowed {
320 if URL_BEARING.contains(&attr) && url_attribute(tag, attr).is_none() {
321 gaps.push(format!("<{tag} {attr}>"));
322 }
323 }
324 }
325 assert!(
326 gaps.is_empty(),
327 "url_attribute() does not cover these allowed URL-bearing attributes \
328 (javascript: URLs would leak through them): {gaps:?}"
329 );
330 }
331
332 /// Behavioural counterpart: a javascript: URL in each covered URL attribute
333 /// must be stripped (never echoed into the output).
334 #[test]
335 fn javascript_urls_are_stripped_from_url_attributes() {
336 for html in [
337 "<a href=\"javascript:alert(1)\">x</a>",
338 "<img src=\"javascript:alert(1)\" alt=\"a\">",
339 "<video poster=\"javascript:alert(1)\"></video>",
340 "<img srcset=\"javascript:alert(1) 1x\" alt=\"a\">",
341 ] {
342 let out = clean(html);
343 assert!(
344 !out.to_lowercase().contains("javascript:"),
345 "javascript: URL survived sanitization: {html} -> {out}"
346 );
347 }
348 }
349
350 #[test]
351 fn strips_iframe_object_embed_form() {
352 for tag in ["iframe", "object", "embed", "form"] {
353 let out = clean(&format!("<{tag}>x</{tag}><p>keep</p>"));
354 assert!(!out.contains(&format!("<{tag}")), "{tag} must be stripped");
355 assert!(out.contains("keep"));
356 }
357 }
358
359 #[test]
360 fn rejects_external_image_src_and_records_it() {
361 let (out, rej) = sanitize_html("<img src=\"https://evil.com/x.png\" alt=\"a\">", &policy());
362 assert!(!out.contains("evil.com"));
363 assert_eq!(rej.len(), 1);
364 assert!(matches!(rej[0].kind, RejectionKind::ExternalUrl));
365 assert_eq!(rej[0].location, "img src");
366 }
367
368 #[test]
369 fn keeps_internal_and_relative_media() {
370 let out = clean(
371 "<img src=\"/static/p.png\" alt=\"a\"><img src=\"https://cdn.makenot.work/b\" alt=\"b\">",
372 );
373 assert!(out.contains("/static/p.png"));
374 assert!(out.contains("cdn.makenot.work/b"));
375 }
376
377 #[test]
378 fn drops_javascript_href_and_records() {
379 let (out, rej) = sanitize_html("<a href=\"javascript:alert(1)\">x</a>", &policy());
380 assert!(!out.to_lowercase().contains("javascript"));
381 assert!(
382 rej.iter()
383 .any(|r| matches!(r.kind, RejectionKind::DisallowedScheme))
384 );
385 }
386
387 #[test]
388 fn anchors_get_nofollow_ugc() {
389 let out = clean("<a href=\"/alice\">me</a>");
390 assert!(out.contains("rel=\"nofollow ugc\""));
391 }
392
393 #[test]
394 fn drops_autoplay_and_loop_audio_attrs() {
395 let out = clean("<audio src=\"/a.mp3\" controls loop autoplay muted></audio>");
396 assert!(out.contains("controls"));
397 assert!(!out.contains("autoplay"));
398 assert!(!out.contains("loop"));
399 assert!(!out.contains("muted"));
400 }
401
402 #[test]
403 fn drops_video_autoplay_keeps_loop() {
404 let out = clean("<video src=\"/v.mp4\" controls loop autoplay></video>");
405 assert!(out.contains("loop"));
406 assert!(!out.contains("autoplay"));
407 }
408
409 #[test]
410 fn srcset_with_external_candidate_is_dropped() {
411 let (out, rej) = sanitize_html(
412 "<img srcset=\"/a.png 1x, https://evil.com/b.png 2x\" alt=\"a\">",
413 &policy(),
414 );
415 assert!(!out.contains("evil.com"));
416 assert!(!out.contains("srcset"));
417 assert!(!rej.is_empty());
418 }
419
420 #[test]
421 fn comments_stripped() {
422 let out = clean("<p>a</p><!-- secret -->");
423 assert!(!out.contains("secret"));
424 }
425
426 #[test]
427 fn idempotent() {
428 let input = "<section><a href=\"/x\">l</a><img src=\"https://evil.com/y\"><script>z</script></section>";
429 let once = clean(input);
430 let twice = clean(&once);
431 assert_eq!(once, twice);
432 }
433 }
434
435 #[cfg(test)]
436 mod proptests {
437 use super::*;
438 use proptest::prelude::*;
439
440 fn policy() -> UrlPolicy {
441 UrlPolicy::new(
442 "https://u.makenot.work/a/p",
443 [
444 "makenot.work".to_string(),
445 "u.makenot.work".to_string(),
446 "cdn.makenot.work".to_string(),
447 ],
448 )
449 .unwrap()
450 }
451
452 proptest! {
453 // Arbitrary input must never panic, and no dangerous *element* may
454 // survive. (We only assert on tags, not substrings: a real `<script`
455 // can only appear as an element, angle brackets in text are escaped.)
456 #[test]
457 fn never_panics_no_dangerous_tags(input in "\\PC{0,400}") {
458 let (out, _rej) = sanitize_html(&input, &policy());
459 let low = out.to_lowercase();
460 for tag in ["<script", "<iframe", "<object", "<embed", "<form",
461 "<style", "<svg", "<math", "<link", "<meta", "<base"] {
462 prop_assert!(!low.contains(tag), "leaked {tag}: {out}");
463 }
464 }
465
466 // Output is stable under re-sanitization (ammonia idempotency).
467 #[test]
468 fn idempotent_fuzz(input in "\\PC{0,400}") {
469 let once = sanitize_html(&input, &policy()).0;
470 let twice = sanitize_html(&once, &policy()).0;
471 prop_assert_eq!(once, twice);
472 }
473
474 // A randomly-built external image src is always stripped.
475 #[test]
476 fn external_img_always_stripped(host in "[a-z]{3,10}", tld in "(com|net|io|xyz)") {
477 let domain = format!("{host}.{tld}");
478 let html = format!("<img src=\"https://{domain}/p.png\" alt=\"x\">");
479 let out = sanitize_html(&html, &policy()).0;
480 prop_assert!(!out.contains(&domain));
481 }
482 }
483 }
484