//! Full-stack XSS sanitization tests. //! //! These tests submit malicious payloads through the actual HTTP handlers //! (thread creation, replies, footnotes) and verify the rendered HTML is safe. use crate::harness::TestHarness; /// Helper: set up a community with a logged-in member. Returns (user_id, thread_url_prefix). async fn setup_community(h: &mut TestHarness) -> (uuid::Uuid, String) { let user_id = h.login_as("xsstester").await; let comm_id = h.create_community("XSS Test", "xsstest").await; let _cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; (user_id, "/p/xsstest/general".to_string()) } /// Assert that the HTML response contains none of the dangerous patterns. /// /// Note: we do NOT check for `` for htmx, toast, and quoting JS. Each test /// individually asserts its specific payload (e.g., `alert('xss')`) is absent. fn assert_no_xss(html: &str, context: &str) { let lower = html.to_lowercase(); assert!( !lower.contains("onerror="), "{context}: found onerror handler" ); assert!( !lower.contains("onmouseover="), "{context}: found onmouseover handler" ); assert!( !lower.contains("onload="), "{context}: found onload handler" ); assert!( !lower.contains("onfocus="), "{context}: found onfocus handler" ); assert!( !lower.contains("onclick="), "{context}: found onclick handler" ); assert!( !lower.contains("javascript:"), "{context}: found javascript: URL" ); assert!( !lower.contains("vbscript:"), "{context}: found vbscript: URL" ); // data: URLs in href/src context assert!( !lower.contains("href=\"data:"), "{context}: found data: URL in href" ); assert!( !lower.contains("src=\"data:"), "{context}: found data: URL in src" ); } // Script injection in post body #[tokio::test] async fn script_tag_in_reply_stripped() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Script Test", "Clean OP") .await; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("{prefix}/{thread_id}/reply"); let payload = urlencoding::encode(""); h.client .post_form(&reply_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; assert_no_xss(&resp.text, "script tag in reply"); assert!(!resp.text.contains("alert('xss')")); } #[tokio::test] async fn script_tag_in_thread_body_stripped() { let mut h = TestHarness::new().await; let (_user_id, prefix) = setup_community(&mut h).await; // GET new thread form for CSRF h.client.get(&format!("{prefix}/new")).await; let payload = urlencoding::encode("Some text"); let resp = h .client .post_form( &format!("{prefix}/new"), &format!("title=XSS+Thread&body={payload}"), ) .await; // Follow redirect to thread page if let Some(loc) = resp.header("location") { let resp = h.client.get(loc).await; assert_no_xss(&resp.text, "script tag in thread body"); assert!(!resp.text.contains("document.cookie")); } } // Event handler injection #[tokio::test] async fn img_onerror_in_reply_stripped() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Event Handler Test", "OP") .await; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("{prefix}/{thread_id}/reply"); let payload = urlencoding::encode(r#""#); h.client .post_form(&reply_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; assert_no_xss(&resp.text, "img onerror in reply"); } #[tokio::test] async fn div_onmouseover_in_reply_stripped() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Mouseover Test", "OP") .await; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("{prefix}/{thread_id}/reply"); let payload = urlencoding::encode(r#"
hover me
"#); h.client .post_form(&reply_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; assert_no_xss(&resp.text, "div onmouseover in reply"); } // Dangerous URL schemes in markdown links #[tokio::test] async fn javascript_url_in_markdown_link_sanitized() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "JS URL Test", "OP") .await; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("{prefix}/{thread_id}/reply"); let payload = urlencoding::encode("[click me](javascript:alert(document.domain))"); h.client .post_form(&reply_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; assert_no_xss(&resp.text, "javascript: URL in markdown link"); // The link text should still render assert!(resp.text.contains("click me")); } #[tokio::test] async fn data_url_in_markdown_link_sanitized() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Data URL Test", "OP") .await; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("{prefix}/{thread_id}/reply"); let payload = urlencoding::encode("[xss](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"); h.client .post_form(&reply_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; assert_no_xss(&resp.text, "data: URL in markdown link"); } #[tokio::test] async fn vbscript_url_in_markdown_link_sanitized() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "VBScript Test", "OP") .await; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("{prefix}/{thread_id}/reply"); let payload = urlencoding::encode("[xss](vbscript:MsgBox)"); h.client .post_form(&reply_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; assert_no_xss(&resp.text, "vbscript: URL in markdown link"); } // Mixed markdown + XSS #[tokio::test] async fn mixed_markdown_and_xss_preserves_safe_content() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Mixed Test", "OP") .await; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("{prefix}/{thread_id}/reply"); let payload = urlencoding::encode( "**bold text** *italic* [safe](https://example.com)", ); h.client .post_form(&reply_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; assert_no_xss(&resp.text, "mixed markdown and XSS"); // Safe markdown should render assert!(resp.text.contains("bold text")); assert!(resp.text.contains("italic")); assert!(resp.text.contains("https://example.com")); } // XSS in footnotes #[tokio::test] async fn xss_in_footnote_stripped() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Footnote XSS", "Original post") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let footnote_url = format!("{prefix}/{thread_id}/posts/{post_id}/footnote"); let payload = urlencoding::encode( r#"Correction: and [link](javascript:void(0))"#, ); h.client .post_form(&footnote_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; assert_no_xss(&resp.text, "XSS in footnote"); // Safe text should still be present assert!(resp.text.contains("Correction:")); } // Case-variant evasion attempts #[tokio::test] async fn case_variant_javascript_url_sanitized() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Case Test", "OP") .await; let thread_url = format!("{prefix}/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("{prefix}/{thread_id}/reply"); // Mixed-case javascript: let payload = urlencoding::encode("[xss](JaVaScRiPt:alert(1))"); h.client .post_form(&reply_url, &format!("body={payload}")) .await; let resp = h.client.get(&thread_url).await; let lower = resp.text.to_lowercase(); assert!( !lower.contains("javascript:"), "case-variant javascript: URL should be sanitized" ); } // XSS in thread title (Askama auto-escaping) #[tokio::test] async fn xss_in_thread_title_escaped() { let mut h = TestHarness::new().await; let (_user_id, prefix) = setup_community(&mut h).await; h.client.get(&format!("{prefix}/new")).await; let title = urlencoding::encode(""); let resp = h .client .post_form( &format!("{prefix}/new"), &format!("title={title}&body=Normal+body"), ) .await; // Follow redirect to thread page or category page if let Some(loc) = resp.header("location") { let resp = h.client.get(loc).await; // The user-injected script payload must not appear unescaped assert!( !resp.text.contains(""), "script tag in title should be escaped" ); assert!( !resp.text.contains("alert('title')"), "alert payload should not appear in title" ); } } // Link-preview OG metadata (attacker-controlled remote title/description) #[tokio::test] async fn link_preview_og_metadata_is_escaped() { let mut h = TestHarness::new().await; let (user_id, prefix) = setup_community(&mut h).await; let cat_id = sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM categories WHERE slug = 'general'") .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Link Preview Test", "OP body") .await; let post_id = sqlx::query_scalar::<_, uuid::Uuid>( "SELECT id FROM posts WHERE thread_id = $1 ORDER BY created_at LIMIT 1", ) .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); // OG title/description come from a remote page the poster doesn't control, // treat them as hostile. Inject script + event-handler payloads. mt_db::mutations::insert_link_preview( &h.db, post_id, "https://example.com/article", Some(r""), Some(r#"">"#), ) .await .unwrap(); let thread_url = format!("{prefix}/{thread_id}"); let resp = h.client.get(&thread_url).await; // OG text is auto-escaped (not markdown-sanitized), so the safety property // is that angle brackets are escaped, the payloads must never appear as // live tags/attributes. (Askama escapes `<` as the numeric entity `<`; // the literal text "onerror=" may survive as inert escaped text, so we don't // use the strip-oriented assert_no_xss here.) assert!( !resp.text.contains("