Skip to main content

max / makenotwork

14.9 KB · 464 lines History Blame Raw
1 //! Full-stack XSS sanitization tests.
2 //!
3 //! These tests submit malicious payloads through the actual HTTP handlers
4 //! (thread creation, replies, footnotes) and verify the rendered HTML is safe.
5
6 use crate::harness::TestHarness;
7
8 /// Helper: set up a community with a logged-in member. Returns (user_id, thread_url_prefix).
9 async fn setup_community(h: &mut TestHarness) -> (uuid::Uuid, String) {
10 let user_id = h.login_as("xsstester").await;
11 let comm_id = h.create_community("XSS Test", "xsstest").await;
12 let _cat_id = h.create_category(comm_id, "General", "general").await;
13 h.add_membership(user_id, comm_id, "member").await;
14 (user_id, "/p/xsstest/general".to_string())
15 }
16
17 /// Assert that the HTML response contains none of the dangerous patterns.
18 ///
19 /// Note: we do NOT check for `<script` globally because the page templates
20 /// legitimately include `<script>` for htmx, toast, and quoting JS. Each test
21 /// individually asserts its specific payload (e.g., `alert('xss')`) is absent.
22 fn assert_no_xss(html: &str, context: &str) {
23 let lower = html.to_lowercase();
24 assert!(
25 !lower.contains("onerror="),
26 "{context}: found onerror handler"
27 );
28 assert!(
29 !lower.contains("onmouseover="),
30 "{context}: found onmouseover handler"
31 );
32 assert!(
33 !lower.contains("onload="),
34 "{context}: found onload handler"
35 );
36 assert!(
37 !lower.contains("onfocus="),
38 "{context}: found onfocus handler"
39 );
40 assert!(
41 !lower.contains("onclick="),
42 "{context}: found onclick handler"
43 );
44 assert!(
45 !lower.contains("javascript:"),
46 "{context}: found javascript: URL"
47 );
48 assert!(
49 !lower.contains("vbscript:"),
50 "{context}: found vbscript: URL"
51 );
52 // data: URLs in href/src context
53 assert!(
54 !lower.contains("href=\"data:"),
55 "{context}: found data: URL in href"
56 );
57 assert!(
58 !lower.contains("src=\"data:"),
59 "{context}: found data: URL in src"
60 );
61 }
62
63 // Script injection in post body
64
65 #[tokio::test]
66 async fn script_tag_in_reply_stripped() {
67 let mut h = TestHarness::new().await;
68 let (user_id, prefix) = setup_community(&mut h).await;
69
70 let cat_id =
71 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
72 .fetch_one(&h.db)
73 .await
74 .unwrap();
75 let thread_id = h
76 .create_thread_with_post(cat_id, user_id, "Script Test", "Clean OP")
77 .await;
78
79 let thread_url = format!("{prefix}/{thread_id}");
80 h.client.get(&thread_url).await;
81
82 let reply_url = format!("{prefix}/{thread_id}/reply");
83 let payload = urlencoding::encode("<script>alert('xss')</script>");
84 h.client
85 .post_form(&reply_url, &format!("body={payload}"))
86 .await;
87
88 let resp = h.client.get(&thread_url).await;
89 assert_no_xss(&resp.text, "script tag in reply");
90 assert!(!resp.text.contains("alert('xss')"));
91 }
92
93 #[tokio::test]
94 async fn script_tag_in_thread_body_stripped() {
95 let mut h = TestHarness::new().await;
96 let (_user_id, prefix) = setup_community(&mut h).await;
97
98 // GET new thread form for CSRF
99 h.client.get(&format!("{prefix}/new")).await;
100
101 let payload = urlencoding::encode("<script>document.cookie</script>Some text");
102 let resp = h
103 .client
104 .post_form(
105 &format!("{prefix}/new"),
106 &format!("title=XSS+Thread&body={payload}"),
107 )
108 .await;
109
110 // Follow redirect to thread page
111 if let Some(loc) = resp.header("location") {
112 let resp = h.client.get(loc).await;
113 assert_no_xss(&resp.text, "script tag in thread body");
114 assert!(!resp.text.contains("document.cookie"));
115 }
116 }
117
118 // Event handler injection
119
120 #[tokio::test]
121 async fn img_onerror_in_reply_stripped() {
122 let mut h = TestHarness::new().await;
123 let (user_id, prefix) = setup_community(&mut h).await;
124
125 let cat_id =
126 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
127 .fetch_one(&h.db)
128 .await
129 .unwrap();
130 let thread_id = h
131 .create_thread_with_post(cat_id, user_id, "Event Handler Test", "OP")
132 .await;
133
134 let thread_url = format!("{prefix}/{thread_id}");
135 h.client.get(&thread_url).await;
136
137 let reply_url = format!("{prefix}/{thread_id}/reply");
138 let payload = urlencoding::encode(r#"<img src=x onerror="alert(1)">"#);
139 h.client
140 .post_form(&reply_url, &format!("body={payload}"))
141 .await;
142
143 let resp = h.client.get(&thread_url).await;
144 assert_no_xss(&resp.text, "img onerror in reply");
145 }
146
147 #[tokio::test]
148 async fn div_onmouseover_in_reply_stripped() {
149 let mut h = TestHarness::new().await;
150 let (user_id, prefix) = setup_community(&mut h).await;
151
152 let cat_id =
153 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
154 .fetch_one(&h.db)
155 .await
156 .unwrap();
157 let thread_id = h
158 .create_thread_with_post(cat_id, user_id, "Mouseover Test", "OP")
159 .await;
160
161 let thread_url = format!("{prefix}/{thread_id}");
162 h.client.get(&thread_url).await;
163
164 let reply_url = format!("{prefix}/{thread_id}/reply");
165 let payload = urlencoding::encode(r#"<div onmouseover="alert(1)">hover me</div>"#);
166 h.client
167 .post_form(&reply_url, &format!("body={payload}"))
168 .await;
169
170 let resp = h.client.get(&thread_url).await;
171 assert_no_xss(&resp.text, "div onmouseover in reply");
172 }
173
174 // Dangerous URL schemes in markdown links
175
176 #[tokio::test]
177 async fn javascript_url_in_markdown_link_sanitized() {
178 let mut h = TestHarness::new().await;
179 let (user_id, prefix) = setup_community(&mut h).await;
180
181 let cat_id =
182 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
183 .fetch_one(&h.db)
184 .await
185 .unwrap();
186 let thread_id = h
187 .create_thread_with_post(cat_id, user_id, "JS URL Test", "OP")
188 .await;
189
190 let thread_url = format!("{prefix}/{thread_id}");
191 h.client.get(&thread_url).await;
192
193 let reply_url = format!("{prefix}/{thread_id}/reply");
194 let payload = urlencoding::encode("[click me](javascript:alert(document.domain))");
195 h.client
196 .post_form(&reply_url, &format!("body={payload}"))
197 .await;
198
199 let resp = h.client.get(&thread_url).await;
200 assert_no_xss(&resp.text, "javascript: URL in markdown link");
201 // The link text should still render
202 assert!(resp.text.contains("click me"));
203 }
204
205 #[tokio::test]
206 async fn data_url_in_markdown_link_sanitized() {
207 let mut h = TestHarness::new().await;
208 let (user_id, prefix) = setup_community(&mut h).await;
209
210 let cat_id =
211 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
212 .fetch_one(&h.db)
213 .await
214 .unwrap();
215 let thread_id = h
216 .create_thread_with_post(cat_id, user_id, "Data URL Test", "OP")
217 .await;
218
219 let thread_url = format!("{prefix}/{thread_id}");
220 h.client.get(&thread_url).await;
221
222 let reply_url = format!("{prefix}/{thread_id}/reply");
223 let payload =
224 urlencoding::encode("[xss](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)");
225 h.client
226 .post_form(&reply_url, &format!("body={payload}"))
227 .await;
228
229 let resp = h.client.get(&thread_url).await;
230 assert_no_xss(&resp.text, "data: URL in markdown link");
231 }
232
233 #[tokio::test]
234 async fn vbscript_url_in_markdown_link_sanitized() {
235 let mut h = TestHarness::new().await;
236 let (user_id, prefix) = setup_community(&mut h).await;
237
238 let cat_id =
239 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
240 .fetch_one(&h.db)
241 .await
242 .unwrap();
243 let thread_id = h
244 .create_thread_with_post(cat_id, user_id, "VBScript Test", "OP")
245 .await;
246
247 let thread_url = format!("{prefix}/{thread_id}");
248 h.client.get(&thread_url).await;
249
250 let reply_url = format!("{prefix}/{thread_id}/reply");
251 let payload = urlencoding::encode("[xss](vbscript:MsgBox)");
252 h.client
253 .post_form(&reply_url, &format!("body={payload}"))
254 .await;
255
256 let resp = h.client.get(&thread_url).await;
257 assert_no_xss(&resp.text, "vbscript: URL in markdown link");
258 }
259
260 // Mixed markdown + XSS
261
262 #[tokio::test]
263 async fn mixed_markdown_and_xss_preserves_safe_content() {
264 let mut h = TestHarness::new().await;
265 let (user_id, prefix) = setup_community(&mut h).await;
266
267 let cat_id =
268 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
269 .fetch_one(&h.db)
270 .await
271 .unwrap();
272 let thread_id = h
273 .create_thread_with_post(cat_id, user_id, "Mixed Test", "OP")
274 .await;
275
276 let thread_url = format!("{prefix}/{thread_id}");
277 h.client.get(&thread_url).await;
278
279 let reply_url = format!("{prefix}/{thread_id}/reply");
280 let payload = urlencoding::encode(
281 "**bold text** <script>alert(1)</script> *italic* [safe](https://example.com)",
282 );
283 h.client
284 .post_form(&reply_url, &format!("body={payload}"))
285 .await;
286
287 let resp = h.client.get(&thread_url).await;
288 assert_no_xss(&resp.text, "mixed markdown and XSS");
289 // Safe markdown should render
290 assert!(resp.text.contains("<strong>bold text</strong>"));
291 assert!(resp.text.contains("<em>italic</em>"));
292 assert!(resp.text.contains("https://example.com"));
293 }
294
295 // XSS in footnotes
296
297 #[tokio::test]
298 async fn xss_in_footnote_stripped() {
299 let mut h = TestHarness::new().await;
300 let (user_id, prefix) = setup_community(&mut h).await;
301
302 let cat_id =
303 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
304 .fetch_one(&h.db)
305 .await
306 .unwrap();
307 let thread_id = h
308 .create_thread_with_post(cat_id, user_id, "Footnote XSS", "Original post")
309 .await;
310
311 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
312 .await
313 .unwrap();
314 let post_id = posts[0].id;
315
316 let thread_url = format!("{prefix}/{thread_id}");
317 h.client.get(&thread_url).await;
318
319 let footnote_url = format!("{prefix}/{thread_id}/posts/{post_id}/footnote");
320 let payload = urlencoding::encode(
321 r#"Correction: <img src=x onerror="alert(1)"> and [link](javascript:void(0))"#,
322 );
323 h.client
324 .post_form(&footnote_url, &format!("body={payload}"))
325 .await;
326
327 let resp = h.client.get(&thread_url).await;
328 assert_no_xss(&resp.text, "XSS in footnote");
329 // Safe text should still be present
330 assert!(resp.text.contains("Correction:"));
331 }
332
333 // Case-variant evasion attempts
334
335 #[tokio::test]
336 async fn case_variant_javascript_url_sanitized() {
337 let mut h = TestHarness::new().await;
338 let (user_id, prefix) = setup_community(&mut h).await;
339
340 let cat_id =
341 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
342 .fetch_one(&h.db)
343 .await
344 .unwrap();
345 let thread_id = h
346 .create_thread_with_post(cat_id, user_id, "Case Test", "OP")
347 .await;
348
349 let thread_url = format!("{prefix}/{thread_id}");
350 h.client.get(&thread_url).await;
351
352 let reply_url = format!("{prefix}/{thread_id}/reply");
353
354 // Mixed-case javascript:
355 let payload = urlencoding::encode("[xss](JaVaScRiPt:alert(1))");
356 h.client
357 .post_form(&reply_url, &format!("body={payload}"))
358 .await;
359
360 let resp = h.client.get(&thread_url).await;
361 let lower = resp.text.to_lowercase();
362 assert!(
363 !lower.contains("javascript:"),
364 "case-variant javascript: URL should be sanitized"
365 );
366 }
367
368 // XSS in thread title (Askama auto-escaping)
369
370 #[tokio::test]
371 async fn xss_in_thread_title_escaped() {
372 let mut h = TestHarness::new().await;
373 let (_user_id, prefix) = setup_community(&mut h).await;
374
375 h.client.get(&format!("{prefix}/new")).await;
376
377 let title = urlencoding::encode("<script>alert('title')</script>");
378 let resp = h
379 .client
380 .post_form(
381 &format!("{prefix}/new"),
382 &format!("title={title}&body=Normal+body"),
383 )
384 .await;
385
386 // Follow redirect to thread page or category page
387 if let Some(loc) = resp.header("location") {
388 let resp = h.client.get(loc).await;
389 // The user-injected script payload must not appear unescaped
390 assert!(
391 !resp.text.contains("<script>alert('title')</script>"),
392 "script tag in title should be escaped"
393 );
394 assert!(
395 !resp.text.contains("alert('title')"),
396 "alert payload should not appear in title"
397 );
398 }
399 }
400
401 // Link-preview OG metadata (attacker-controlled remote title/description)
402
403 #[tokio::test]
404 async fn link_preview_og_metadata_is_escaped() {
405 let mut h = TestHarness::new().await;
406 let (user_id, prefix) = setup_community(&mut h).await;
407
408 let cat_id =
409 sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'")
410 .fetch_one(&h.db)
411 .await
412 .unwrap();
413 let thread_id = h
414 .create_thread_with_post(cat_id, user_id, "Link Preview Test", "OP body")
415 .await;
416
417 let post_id = sqlx::query_scalar::<_, uuid::Uuid>(
418 "SELECT id FROM posts WHERE thread_id = $1 ORDER BY created_at LIMIT 1",
419 )
420 .bind(thread_id)
421 .fetch_one(&h.db)
422 .await
423 .unwrap();
424
425 // OG title/description come from a remote page the poster doesn't control,
426 // treat them as hostile. Inject script + event-handler payloads.
427 mt_db::mutations::insert_link_preview(
428 &h.db,
429 post_id,
430 "https://example.com/article",
431 Some(r"<script>alert('ogtitle')</script>"),
432 Some(r#""><img src=x onerror="alert('ogdesc')">"#),
433 )
434 .await
435 .unwrap();
436
437 let thread_url = format!("{prefix}/{thread_id}");
438 let resp = h.client.get(&thread_url).await;
439
440 // OG text is auto-escaped (not markdown-sanitized), so the safety property
441 // is that angle brackets are escaped, the payloads must never appear as
442 // live tags/attributes. (Askama escapes `<` as the numeric entity `&#60;`;
443 // the literal text "onerror=" may survive as inert escaped text, so we don't
444 // use the strip-oriented assert_no_xss here.)
445 assert!(
446 !resp.text.contains("<script>alert('ogtitle')"),
447 "og:title <script> must be escaped, not a live tag"
448 );
449 assert!(
450 !resp.text.contains("<img src=x onerror="),
451 "og:description <img onerror> must be escaped, not a live tag"
452 );
453 // The escaped form should be present, proving the card rendered the value
454 // through HTML escaping (`<` -> `&#60;`).
455 assert!(
456 resp.text.contains("&#60;script&#62;") && resp.text.contains("&#60;img"),
457 "og payload should appear HTML-escaped"
458 );
459 assert!(
460 resp.text.contains("link-preview-card"),
461 "preview card should render"
462 );
463 }
464