#![allow(missing_docs, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use feedparser_rs::{ParseOptions, parse, parse_with_options};
const DANGEROUS_MARKERS: &[&str] = &["<script", "onerror=", "onbegin="];
fn assert_no_dangerous_markup(haystack: &str) {
for marker in DANGEROUS_MARKERS {
assert!(
!haystack.contains(marker),
"expected sanitized output to not contain {marker:?}, got: {haystack}"
);
}
}
const TITLE_PAYLOAD: &str = "<script>alert(1)</script>Post";
#[test]
fn test_rss20_xss_payload_sanitized_by_default() {
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>{TITLE_PAYLOAD}</title>
<item>
<title>{TITLE_PAYLOAD}</title>
<description><script>alert(1)</script><img src=x onerror=alert(1)></description>
</item>
</channel>
</rss>"#
);
let feed = parse(xml.as_bytes()).unwrap();
assert!(!feed.bozo);
assert_no_dangerous_markup(feed.feed.title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_atom_xss_payload_sanitized_by_default() {
let xml = format!(
r#"<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{TITLE_PAYLOAD}</title>
<entry>
<title>{TITLE_PAYLOAD}</title>
<summary type="html"><script>alert(1)</script><svg><animate onbegin="alert(1)" attributeName="x"></svg></summary>
</entry>
</feed>"#
);
let feed = parse(xml.as_bytes()).unwrap();
assert!(!feed.bozo);
assert_no_dangerous_markup(feed.feed.title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_rss10_xss_payload_sanitized_by_default() {
let xml = format!(
r#"<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/">
<channel rdf:about="http://example.com/">
<title>{TITLE_PAYLOAD}</title>
<link>http://example.com/</link>
<description>Feed description</description>
</channel>
<item rdf:about="http://example.com/1">
<title>{TITLE_PAYLOAD}</title>
<description><script>alert(1)</script><img src=x onerror=alert(1)></description>
</item>
</rdf:RDF>"#
);
let feed = parse(xml.as_bytes()).unwrap();
assert!(!feed.bozo);
assert_no_dangerous_markup(feed.feed.title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_json_feed_xss_payload_sanitized_by_default() {
let json = br#"{
"version": "https://jsonfeed.org/version/1.1",
"title": "<script>alert(1)</script>Feed",
"description": "<script>alert(1)</script>Description",
"items": [
{
"id": "1",
"title": "<script>alert(1)</script>Post",
"summary": "<script>alert(1)</script>Summary",
"content_html": "<script>alert(1)</script><img src=x onerror=alert(1)>"
}
]
}"#;
let feed = parse(json).unwrap();
assert!(!feed.bozo);
assert_no_dangerous_markup(feed.feed.title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.feed.subtitle.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].content[0].value.as_str());
}
fn atom_summary_feed(type_attr_and_body: &str) -> Vec<u8> {
format!(
r#"<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Feed</title>
<entry>
<title>Post</title>
{type_attr_and_body}
</entry>
</feed>"#
)
.into_bytes()
}
#[test]
fn test_atom_type_html_keyword_is_sanitized() {
let xml = atom_summary_feed(
r#"<summary type="html"><script>alert(1)</script>Hi</summary>"#,
);
let feed = parse(&xml).unwrap();
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_atom_type_text_html_mime_spelling_is_sanitized() {
let xml = atom_summary_feed(
r#"<summary type="text/html"><script>alert(1)</script>Hi</summary>"#,
);
let feed = parse(&xml).unwrap();
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_atom_type_application_xhtml_xml_mime_spelling_is_sanitized() {
let xml = atom_summary_feed(
r#"<summary type="application/xhtml+xml"><div xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script>Hi</div></summary>"#,
);
let feed = parse(&xml).unwrap();
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_atom_type_uppercase_html_is_sanitized() {
let xml = atom_summary_feed(
r#"<summary type="HTML"><script>alert(1)</script>Hi</summary>"#,
);
let feed = parse(&xml).unwrap();
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_atom_type_absent_is_sanitized() {
let xml = atom_summary_feed(r"<summary><script>alert(1)</script>Hi</summary>");
let feed = parse(&xml).unwrap();
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_atom_type_absent_preserves_harmless_plain_text() {
let xml = atom_summary_feed(r"<summary>Plain text, not markup</summary>");
let feed = parse(&xml).unwrap();
assert_eq!(
feed.entries[0].summary.as_deref(),
Some("Plain text, not markup")
);
}
#[test]
fn test_atom_type_literal_text_is_not_sanitized() {
let xml = atom_summary_feed(r#"<summary type="text">Plain & simple</summary>"#);
let feed = parse(&xml).unwrap();
assert_eq!(feed.entries[0].summary.as_deref(), Some("Plain & simple"));
}
const fn xss_rss_feed() -> &'static [u8] {
br#"<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title><script>alert(1)</script>Feed</title>
<item>
<title><script>alert(1)</script>Post</title>
<description><script>alert(1)</script>Hi</description>
</item>
</channel>
</rss>"#
}
#[test]
fn test_sanitize_html_false_preserves_raw_html() {
let options = ParseOptions {
sanitize_html: false,
..ParseOptions::default()
};
let feed = parse_with_options(xss_rss_feed(), &options).unwrap();
assert_eq!(
feed.entries[0].summary.as_deref(),
Some("<script>alert(1)</script>Hi")
);
}
#[test]
fn test_sanitize_html_default_true_strips_script() {
let feed = parse_with_options(xss_rss_feed(), &ParseOptions::default()).unwrap();
assert_no_dangerous_markup(feed.feed.title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].title.as_deref().unwrap_or(""));
assert_no_dangerous_markup(feed.entries[0].summary.as_deref().unwrap_or(""));
}
#[test]
fn test_sanitize_html_is_idempotent() {
let first = parse(xss_rss_feed()).unwrap();
let second = parse(xss_rss_feed()).unwrap();
assert_eq!(first.feed.title, second.feed.title);
assert_eq!(first.entries[0].title, second.entries[0].title);
assert_eq!(first.entries[0].summary, second.entries[0].summary);
let mut third = first.clone();
feedparser_rs::util::sanitize::sanitize_feed(
&mut third,
&feedparser_rs::ParserLimits::default(),
);
assert_eq!(first.feed.title, third.feed.title);
assert_eq!(first.entries[0].title, third.entries[0].title);
assert_eq!(first.entries[0].summary, third.entries[0].summary);
assert_eq!(
first.entries[0].title_detail.as_ref().map(|d| &d.value),
third.entries[0].title_detail.as_ref().map(|d| &d.value)
);
}
#[test]
fn test_pathologically_nested_html_is_bounded_not_sanitized_unbounded() {
let nesting_depth = 20_000;
let mut description = String::with_capacity(nesting_depth * 11);
for _ in 0..nesting_depth {
description.push_str("<div>");
}
description.push_str("<script>alert(1)</script>");
for _ in 0..nesting_depth {
description.push_str("</div>");
}
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{description}]]></description></item>
</channel></rss>"#
);
let start = std::time::Instant::now();
let feed = parse(xml.as_bytes()).unwrap();
let elapsed = start.elapsed();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert_no_dangerous_markup(summary);
assert!(
!summary.contains('<'),
"expected all tags escaped by the nesting-depth fallback, got a tag in: {summary}"
);
assert!(
elapsed.as_secs() < 5,
"parsing pathologically nested HTML took too long: {elapsed:?}"
);
}
#[test]
fn test_moderately_nested_html_is_still_sanitized_normally() {
let xml = br#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><div><p><b>bold</b></p></div></description></item>
</channel></rss>"#;
let feed = parse(xml).unwrap();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert_eq!(summary, "<div><p><b>bold</b></p></div>");
}
#[test]
fn test_mismatched_closing_tags_cannot_bypass_the_nesting_guard() {
let repeats = 2_000;
let mut description = String::with_capacity(repeats * 12);
for _ in 0..repeats {
description.push_str("<div></x>");
}
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{description}]]></description></item>
</channel></rss>"#
);
let start = std::time::Instant::now();
let feed = parse(xml.as_bytes()).unwrap();
let elapsed = start.elapsed();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert!(
!summary.contains("<div"),
"expected the nesting-depth fallback to engage on a mismatched-closing-tag \
bypass attempt, got: {summary}"
);
assert!(
elapsed.as_millis() < 500,
"mismatched-closing-tag input must not reach ammonia's unbounded tree \
builder; took {elapsed:?}"
);
}
#[test]
fn test_many_void_elements_are_not_treated_as_deeply_nested() {
let description: String = "<img src=\"x.png\">".repeat(150);
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{description}]]></description></item>
</channel></rss>"#
);
let feed = parse(xml.as_bytes()).unwrap();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert!(
summary.contains("<img"),
"150 <img> tags must not trip the nesting-depth fallback, got: {summary}"
);
}
#[test]
fn test_many_unclosed_auto_closing_elements_are_not_deeply_nested() {
let items: String = "<li>item".repeat(150);
let description = format!("<ul>{items}</ul>");
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{description}]]></description></item>
</channel></rss>"#
);
let feed = parse(xml.as_bytes()).unwrap();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert!(
summary.contains("<li"),
"150 unclosed <li> elements must not trip the nesting-depth fallback, got: {summary}"
);
}
#[test]
fn test_scope_barrier_cannot_bypass_the_nesting_guard() {
let repeats = 2_000;
let mut description = String::with_capacity(repeats * 19);
for _ in 0..repeats {
description.push_str("<div><table></div>");
}
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{description}]]></description></item>
</channel></rss>"#
);
let start = std::time::Instant::now();
let feed = parse(xml.as_bytes()).unwrap();
let elapsed = start.elapsed();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert!(
!summary.contains("<div") && !summary.contains("<table"),
"expected the nesting-depth fallback to engage on the table scope-barrier \
bypass attempt, got: {summary}"
);
assert!(
elapsed.as_millis() < 500,
"table scope-barrier bypass input must not reach ammonia's unbounded tree \
builder; took {elapsed:?}"
);
}
#[test]
fn test_mixed_tag_auto_close_run_is_not_deeply_nested() {
let paragraphs: String = "<p><span>Text ".repeat(60);
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{paragraphs}]]></description></item>
</channel></rss>"#
);
let feed = parse(xml.as_bytes()).unwrap();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert!(
summary.contains("<p") && summary.contains("<span"),
"a run of <p><span> pairs auto-closing the previous <p> must not trip \
the nesting-depth fallback, got: {summary}"
);
}
#[test]
fn test_many_unclosed_formatting_elements_are_not_deeply_nested() {
let description: String = "<b>bold ".repeat(150);
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{description}]]></description></item>
</channel></rss>"#
);
let feed = parse(xml.as_bytes()).unwrap();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert!(
summary.contains("<b>"),
"150 unclosed <b> elements must not trip the nesting-depth fallback, got: {summary}"
);
}
#[test]
fn test_excessive_flat_tag_count_trips_the_max_tags_backstop() {
let description: String = "<br>".repeat(10_001);
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{description}]]></description></item>
</channel></rss>"#
);
let feed = parse(xml.as_bytes()).unwrap();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert!(
!summary.contains("<br"),
"expected the max-tags backstop to engage past 10,000 tags in one field, \
got a <br> tag in: {summary}"
);
}
#[test]
fn test_unclosed_th_header_row_is_not_deeply_nested() {
let headers: String = "<th>Header ".repeat(60);
let description = format!("<table><tr>{headers}</tr></table>");
let xml = format!(
r#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>F</title>
<item><title>T</title><description><![CDATA[{description}]]></description></item>
</channel></rss>"#
);
let feed = parse(xml.as_bytes()).unwrap();
let summary = feed.entries[0].summary.as_deref().unwrap_or("");
assert!(
summary.contains("<th"),
"60 unclosed <th> cells in one row must not trip the nesting-depth fallback, \
got: {summary}"
);
}