//! 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!("{}/{}/reply", prefix, thread_id); 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!("{}/new", prefix)).await; let payload = urlencoding::encode("Some text"); let resp = h .client .post_form( &format!("{}/new", prefix), &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!("{}/{}/reply", prefix, thread_id); 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!("{}/{}/reply", prefix, thread_id); 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!("{}/{}/reply", prefix, thread_id); 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!("{}/{}/reply", prefix, thread_id); 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!("{}/{}/reply", prefix, thread_id); 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!("{}/{}/reply", prefix, thread_id); 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!("{}/{}/posts/{}/footnote", prefix, thread_id, post_id); 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!("{}/{}/reply", prefix, thread_id); // 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!("{}/new", prefix)).await; let title = urlencoding::encode(""); let resp = h .client .post_form( &format!("{}/new", prefix), &format!("title={}&body=Normal+body", title), ) .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"); } }