//! 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 `");
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");
}
}