Skip to main content

max / multithreaded

13.5 KB · 399 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!(!lower.contains("onerror="), "{context}: found onerror handler");
25 assert!(!lower.contains("onmouseover="), "{context}: found onmouseover handler");
26 assert!(!lower.contains("onload="), "{context}: found onload handler");
27 assert!(!lower.contains("onfocus="), "{context}: found onfocus handler");
28 assert!(!lower.contains("onclick="), "{context}: found onclick handler");
29 assert!(
30 !lower.contains("javascript:"),
31 "{context}: found javascript: URL"
32 );
33 assert!(
34 !lower.contains("vbscript:"),
35 "{context}: found vbscript: URL"
36 );
37 // data: URLs in href/src context
38 assert!(
39 !lower.contains("href=\"data:"),
40 "{context}: found data: URL in href"
41 );
42 assert!(
43 !lower.contains("src=\"data:"),
44 "{context}: found data: URL in src"
45 );
46 }
47
48 // ============================================================================
49 // Script injection in post body
50 // ============================================================================
51
52 #[tokio::test]
53 async fn script_tag_in_reply_stripped() {
54 let mut h = TestHarness::new().await;
55 let (user_id, prefix) = setup_community(&mut h).await;
56
57 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
58 "SELECT id FROM categories WHERE slug = 'general'",
59 )
60 .fetch_one(&h.db)
61 .await
62 .unwrap();
63 let thread_id = h
64 .create_thread_with_post(cat_id, user_id, "Script Test", "Clean OP")
65 .await;
66
67 let thread_url = format!("{}/{}", prefix, thread_id);
68 h.client.get(&thread_url).await;
69
70 let reply_url = format!("{}/{}/reply", prefix, thread_id);
71 let payload = urlencoding::encode("<script>alert('xss')</script>");
72 h.client
73 .post_form(&reply_url, &format!("body={}", payload))
74 .await;
75
76 let resp = h.client.get(&thread_url).await;
77 assert_no_xss(&resp.text, "script tag in reply");
78 assert!(!resp.text.contains("alert('xss')"));
79 }
80
81 #[tokio::test]
82 async fn script_tag_in_thread_body_stripped() {
83 let mut h = TestHarness::new().await;
84 let (_user_id, prefix) = setup_community(&mut h).await;
85
86 // GET new thread form for CSRF
87 h.client.get(&format!("{}/new", prefix)).await;
88
89 let payload = urlencoding::encode("<script>document.cookie</script>Some text");
90 let resp = h
91 .client
92 .post_form(
93 &format!("{}/new", prefix),
94 &format!("title=XSS+Thread&body={}", payload),
95 )
96 .await;
97
98 // Follow redirect to thread page
99 if let Some(loc) = resp.header("location") {
100 let resp = h.client.get(loc).await;
101 assert_no_xss(&resp.text, "script tag in thread body");
102 assert!(!resp.text.contains("document.cookie"));
103 }
104 }
105
106 // ============================================================================
107 // Event handler injection
108 // ============================================================================
109
110 #[tokio::test]
111 async fn img_onerror_in_reply_stripped() {
112 let mut h = TestHarness::new().await;
113 let (user_id, prefix) = setup_community(&mut h).await;
114
115 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
116 "SELECT id FROM categories WHERE slug = 'general'",
117 )
118 .fetch_one(&h.db)
119 .await
120 .unwrap();
121 let thread_id = h
122 .create_thread_with_post(cat_id, user_id, "Event Handler Test", "OP")
123 .await;
124
125 let thread_url = format!("{}/{}", prefix, thread_id);
126 h.client.get(&thread_url).await;
127
128 let reply_url = format!("{}/{}/reply", prefix, thread_id);
129 let payload = urlencoding::encode(r#"<img src=x onerror="alert(1)">"#);
130 h.client
131 .post_form(&reply_url, &format!("body={}", payload))
132 .await;
133
134 let resp = h.client.get(&thread_url).await;
135 assert_no_xss(&resp.text, "img onerror in reply");
136 }
137
138 #[tokio::test]
139 async fn div_onmouseover_in_reply_stripped() {
140 let mut h = TestHarness::new().await;
141 let (user_id, prefix) = setup_community(&mut h).await;
142
143 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
144 "SELECT id FROM categories WHERE slug = 'general'",
145 )
146 .fetch_one(&h.db)
147 .await
148 .unwrap();
149 let thread_id = h
150 .create_thread_with_post(cat_id, user_id, "Mouseover Test", "OP")
151 .await;
152
153 let thread_url = format!("{}/{}", prefix, thread_id);
154 h.client.get(&thread_url).await;
155
156 let reply_url = format!("{}/{}/reply", prefix, thread_id);
157 let payload = urlencoding::encode(r#"<div onmouseover="alert(1)">hover me</div>"#);
158 h.client
159 .post_form(&reply_url, &format!("body={}", payload))
160 .await;
161
162 let resp = h.client.get(&thread_url).await;
163 assert_no_xss(&resp.text, "div onmouseover in reply");
164 }
165
166 // ============================================================================
167 // Dangerous URL schemes in markdown links
168 // ============================================================================
169
170 #[tokio::test]
171 async fn javascript_url_in_markdown_link_sanitized() {
172 let mut h = TestHarness::new().await;
173 let (user_id, prefix) = setup_community(&mut h).await;
174
175 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
176 "SELECT id FROM categories WHERE slug = 'general'",
177 )
178 .fetch_one(&h.db)
179 .await
180 .unwrap();
181 let thread_id = h
182 .create_thread_with_post(cat_id, user_id, "JS URL Test", "OP")
183 .await;
184
185 let thread_url = format!("{}/{}", prefix, thread_id);
186 h.client.get(&thread_url).await;
187
188 let reply_url = format!("{}/{}/reply", prefix, thread_id);
189 let payload = urlencoding::encode("[click me](javascript:alert(document.domain))");
190 h.client
191 .post_form(&reply_url, &format!("body={}", payload))
192 .await;
193
194 let resp = h.client.get(&thread_url).await;
195 assert_no_xss(&resp.text, "javascript: URL in markdown link");
196 // The link text should still render
197 assert!(resp.text.contains("click me"));
198 }
199
200 #[tokio::test]
201 async fn data_url_in_markdown_link_sanitized() {
202 let mut h = TestHarness::new().await;
203 let (user_id, prefix) = setup_community(&mut h).await;
204
205 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
206 "SELECT id FROM categories WHERE slug = 'general'",
207 )
208 .fetch_one(&h.db)
209 .await
210 .unwrap();
211 let thread_id = h
212 .create_thread_with_post(cat_id, user_id, "Data URL Test", "OP")
213 .await;
214
215 let thread_url = format!("{}/{}", prefix, thread_id);
216 h.client.get(&thread_url).await;
217
218 let reply_url = format!("{}/{}/reply", prefix, thread_id);
219 let payload =
220 urlencoding::encode("[xss](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)");
221 h.client
222 .post_form(&reply_url, &format!("body={}", payload))
223 .await;
224
225 let resp = h.client.get(&thread_url).await;
226 assert_no_xss(&resp.text, "data: URL in markdown link");
227 }
228
229 #[tokio::test]
230 async fn vbscript_url_in_markdown_link_sanitized() {
231 let mut h = TestHarness::new().await;
232 let (user_id, prefix) = setup_community(&mut h).await;
233
234 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
235 "SELECT id FROM categories WHERE slug = 'general'",
236 )
237 .fetch_one(&h.db)
238 .await
239 .unwrap();
240 let thread_id = h
241 .create_thread_with_post(cat_id, user_id, "VBScript Test", "OP")
242 .await;
243
244 let thread_url = format!("{}/{}", prefix, thread_id);
245 h.client.get(&thread_url).await;
246
247 let reply_url = format!("{}/{}/reply", prefix, thread_id);
248 let payload = urlencoding::encode("[xss](vbscript:MsgBox)");
249 h.client
250 .post_form(&reply_url, &format!("body={}", payload))
251 .await;
252
253 let resp = h.client.get(&thread_url).await;
254 assert_no_xss(&resp.text, "vbscript: URL in markdown link");
255 }
256
257 // ============================================================================
258 // Mixed markdown + XSS
259 // ============================================================================
260
261 #[tokio::test]
262 async fn mixed_markdown_and_xss_preserves_safe_content() {
263 let mut h = TestHarness::new().await;
264 let (user_id, prefix) = setup_community(&mut h).await;
265
266 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
267 "SELECT id FROM categories WHERE slug = 'general'",
268 )
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!("{}/{}/reply", prefix, thread_id);
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 // ============================================================================
296 // XSS in footnotes
297 // ============================================================================
298
299 #[tokio::test]
300 async fn xss_in_footnote_stripped() {
301 let mut h = TestHarness::new().await;
302 let (user_id, prefix) = setup_community(&mut h).await;
303
304 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
305 "SELECT id FROM categories WHERE slug = 'general'",
306 )
307 .fetch_one(&h.db)
308 .await
309 .unwrap();
310 let thread_id = h
311 .create_thread_with_post(cat_id, user_id, "Footnote XSS", "Original post")
312 .await;
313
314 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
315 .await
316 .unwrap();
317 let post_id = posts[0].id;
318
319 let thread_url = format!("{}/{}", prefix, thread_id);
320 h.client.get(&thread_url).await;
321
322 let footnote_url = format!("{}/{}/posts/{}/footnote", prefix, thread_id, post_id);
323 let payload = urlencoding::encode(
324 r#"Correction: <img src=x onerror="alert(1)"> and [link](javascript:void(0))"#,
325 );
326 h.client
327 .post_form(&footnote_url, &format!("body={}", payload))
328 .await;
329
330 let resp = h.client.get(&thread_url).await;
331 assert_no_xss(&resp.text, "XSS in footnote");
332 // Safe text should still be present
333 assert!(resp.text.contains("Correction:"));
334 }
335
336 // ============================================================================
337 // Case-variant evasion attempts
338 // ============================================================================
339
340 #[tokio::test]
341 async fn case_variant_javascript_url_sanitized() {
342 let mut h = TestHarness::new().await;
343 let (user_id, prefix) = setup_community(&mut h).await;
344
345 let cat_id = sqlx::query_scalar::<_, uuid::Uuid>(
346 "SELECT id FROM categories WHERE slug = 'general'",
347 )
348 .fetch_one(&h.db)
349 .await
350 .unwrap();
351 let thread_id = h
352 .create_thread_with_post(cat_id, user_id, "Case Test", "OP")
353 .await;
354
355 let thread_url = format!("{}/{}", prefix, thread_id);
356 h.client.get(&thread_url).await;
357
358 let reply_url = format!("{}/{}/reply", prefix, thread_id);
359
360 // Mixed-case javascript:
361 let payload = urlencoding::encode("[xss](JaVaScRiPt:alert(1))");
362 h.client
363 .post_form(&reply_url, &format!("body={}", payload))
364 .await;
365
366 let resp = h.client.get(&thread_url).await;
367 let lower = resp.text.to_lowercase();
368 assert!(!lower.contains("javascript:"), "case-variant javascript: URL should be sanitized");
369 }
370
371 // ============================================================================
372 // XSS in thread title (Askama auto-escaping)
373 // ============================================================================
374
375 #[tokio::test]
376 async fn xss_in_thread_title_escaped() {
377 let mut h = TestHarness::new().await;
378 let (_user_id, prefix) = setup_community(&mut h).await;
379
380 h.client.get(&format!("{}/new", prefix)).await;
381
382 let title = urlencoding::encode("<script>alert('title')</script>");
383 let resp = h
384 .client
385 .post_form(
386 &format!("{}/new", prefix),
387 &format!("title={}&body=Normal+body", title),
388 )
389 .await;
390
391 // Follow redirect to thread page or category page
392 if let Some(loc) = resp.header("location") {
393 let resp = h.client.get(loc).await;
394 // The user-injected script payload must not appear unescaped
395 assert!(!resp.text.contains("<script>alert('title')</script>"), "script tag in title should be escaped");
396 assert!(!resp.text.contains("alert('title')"), "alert payload should not appear in title");
397 }
398 }
399