//! 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!("{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("