feedparser_rs/util/sanitize.rs
1//! HTML sanitization utilities
2//!
3//! This module provides functions for sanitizing HTML content to prevent XSS attacks
4//! while preserving safe formatting.
5
6use crate::ParserLimits;
7use crate::types::{Content, Entry, FeedMeta, MimeType, ParsedFeed, TextConstruct, TextType};
8use ammonia::Builder;
9use std::collections::HashSet;
10use std::sync::LazyLock;
11
12/// Tags `sanitize_html` keeps; every other tag is stripped (its children are
13/// kept, unwrapped).
14const SAFE_TAGS: &[&str] = &[
15 // Text formatting
16 "a",
17 "abbr",
18 "acronym",
19 "b",
20 "cite",
21 "code",
22 "em",
23 "i",
24 "kbd",
25 "mark",
26 "s",
27 "samp",
28 "small",
29 "strike",
30 "strong",
31 "sub",
32 "sup",
33 "u",
34 "var", // Structural
35 "br",
36 "div",
37 "hr",
38 "p",
39 "span", // Headings
40 "h1",
41 "h2",
42 "h3",
43 "h4",
44 "h5",
45 "h6", // Lists
46 "dd",
47 "dl",
48 "dt",
49 "li",
50 "ol",
51 "ul", // Tables
52 "caption",
53 "table",
54 "tbody",
55 "td",
56 "tfoot",
57 "th",
58 "thead",
59 "tr", // Quotes
60 "blockquote",
61 "q", // Pre-formatted
62 "pre", // Media
63 "img",
64];
65
66/// Shared, built-once sanitizer configuration.
67///
68/// Built lazily on first use (ammonia uses this same `LazyLock<Builder<'static>>`
69/// pattern internally for its own default cleaner). Rebuilding the tag/attribute
70/// `HashSet`s and `Builder` on every call was measured to cost ~12x on a typical
71/// feed once `sanitize_feed` started calling this function dozens of times per
72/// entry instead of zero (#438) — building it once amortizes that cost to nothing.
73static SAFE_HTML_BUILDER: LazyLock<Builder<'static>> = LazyLock::new(|| {
74 let safe_tags: HashSet<&'static str> = SAFE_TAGS.iter().copied().collect();
75
76 let safe_attrs: HashSet<&'static str> = ["alt", "cite", "class", "href", "id", "src", "title"]
77 .into_iter()
78 .collect();
79
80 let safe_url_schemes: HashSet<&'static str> = ["http", "https", "mailto"].into_iter().collect();
81
82 let mut builder = Builder::default();
83 builder
84 .tags(safe_tags)
85 .generic_attributes(safe_attrs)
86 .link_rel(Some("nofollow noopener noreferrer"))
87 .url_schemes(safe_url_schemes);
88 builder
89});
90
91/// Sanitize HTML content, removing dangerous tags and attributes
92///
93/// This function uses ammonia to clean HTML content, allowing only safe tags
94/// and attributes. It's designed to match feedparser's sanitization behavior.
95///
96/// # Performance
97///
98/// This is a low-level primitive: it always runs the input through ammonia's
99/// HTML5 tree builder, which exhibits quadratic-time behavior on pathologically
100/// deep tag nesting. Prefer [`sanitize_feed`] for parsed feed content — it
101/// applies a nesting-depth bound (`ParserLimits::max_html_nesting_depth`) before
102/// calling this function, falling back to plain-text escaping for input that
103/// exceeds it.
104///
105/// # Arguments
106///
107/// * `input` - HTML string to sanitize
108///
109/// # Returns
110///
111/// Sanitized HTML string with dangerous content removed
112///
113/// # Examples
114///
115/// ```
116/// use feedparser_rs::util::sanitize::sanitize_html;
117///
118/// let unsafe_html = r#"<p>Hello</p><script>alert('XSS')</script>"#;
119/// let safe_html = sanitize_html(unsafe_html);
120/// assert_eq!(safe_html, "<p>Hello</p>");
121/// ```
122pub fn sanitize_html(input: &str) -> String {
123 SAFE_HTML_BUILDER.clean(input).to_string()
124}
125
126/// Decode HTML entities to Unicode characters
127///
128/// # Examples
129///
130/// ```
131/// use feedparser_rs::util::sanitize::decode_entities;
132///
133/// assert_eq!(decode_entities("<p>Hello</p>"), "<p>Hello</p>");
134/// assert_eq!(decode_entities("&amp;"), "&");
135/// ```
136pub fn decode_entities(input: &str) -> String {
137 html_escape::decode_html_entities(input).to_string()
138}
139
140/// Strip all HTML tags, leaving only text content
141///
142/// # Examples
143///
144/// ```
145/// use feedparser_rs::util::sanitize::strip_tags;
146///
147/// assert_eq!(strip_tags("<p>Hello <b>world</b></p>"), "Hello world");
148/// ```
149pub fn strip_tags(input: &str) -> String {
150 Builder::default()
151 .tags(HashSet::new())
152 .clean(input)
153 .to_string()
154}
155
156/// Sanitize every HTML-bearing field of a parsed feed, in place.
157///
158/// This is the single enforcement point for `ParseOptions::sanitize_html`. Format
159/// parsers populate ~85 fields across `FeedMeta` and `Entry` that can carry markup;
160/// sanitizing only at the handful of `set_*` convenience helpers would miss most of
161/// them (all of RSS 1.0, every `entry.content` push, `dc:*`/`media:*` fields). Walking
162/// the fully parsed structure once, after all format-specific parsing has finished,
163/// is the only way to cover every call site without duplicating sanitization logic
164/// into each parser.
165///
166/// Fields are matched against Python feedparser's `can_contain_dangerous_markup` set:
167/// `Tag.term`/`label`, `Person.name`, `Enclosure.title`, `comments`,
168/// `slash_hit_parade`, `Generator.name`, and podcast free-text fields are
169/// deliberately excluded, since they are not rendered as markup by consumers.
170///
171/// # Examples
172///
173/// ```
174/// use feedparser_rs::{ParserLimits, parse, util::sanitize::sanitize_feed};
175///
176/// let xml = br#"<rss version="2.0"><channel><title>Feed</title>
177/// <item><title>Post</title>
178/// <description><script>alert(1)</script>Hi</description></item>
179/// </channel></rss>"#;
180/// let mut feed = parse(xml).unwrap();
181/// sanitize_feed(&mut feed, &ParserLimits::default());
182/// assert!(!feed.entries[0].summary.as_deref().unwrap_or("").contains("<script>"));
183/// ```
184pub fn sanitize_feed(feed: &mut ParsedFeed, limits: &ParserLimits) {
185 sanitize_feed_meta(&mut feed.feed, limits);
186 for entry in &mut feed.entries {
187 sanitize_entry(entry, limits);
188 }
189}
190
191/// Sanitize the HTML-bearing fields of `FeedMeta`.
192fn sanitize_feed_meta(meta: &mut FeedMeta, limits: &ParserLimits) {
193 sanitize_pair(&mut meta.title, &mut meta.title_detail, limits);
194 sanitize_pair(&mut meta.subtitle, &mut meta.subtitle_detail, limits);
195 sanitize_pair(&mut meta.summary, &mut meta.summary_detail, limits);
196 sanitize_pair(&mut meta.rights, &mut meta.rights_detail, limits);
197 sanitize_opt(&mut meta.dc_rights, limits);
198
199 if let Some(image) = &mut meta.image {
200 sanitize_opt(&mut image.title, limits);
201 sanitize_opt(&mut image.description, limits);
202 }
203 if let Some(textinput) = &mut meta.textinput {
204 sanitize_opt(&mut textinput.title, limits);
205 sanitize_opt(&mut textinput.description, limits);
206 }
207 if let Some(itunes) = &mut meta.itunes {
208 sanitize_opt(&mut itunes.subtitle, limits);
209 sanitize_opt(&mut itunes.summary, limits);
210 }
211}
212
213/// Sanitize the HTML-bearing fields of `Entry`.
214fn sanitize_entry(entry: &mut Entry, limits: &ParserLimits) {
215 sanitize_pair(&mut entry.title, &mut entry.title_detail, limits);
216 sanitize_pair(&mut entry.subtitle, &mut entry.subtitle_detail, limits);
217 sanitize_pair(&mut entry.summary, &mut entry.summary_detail, limits);
218 sanitize_pair(&mut entry.rights, &mut entry.rights_detail, limits);
219 sanitize_opt(&mut entry.dc_rights, limits);
220 sanitize_opt(&mut entry.media_title, limits);
221 sanitize_opt(&mut entry.media_description, limits);
222
223 for content in &mut entry.content {
224 sanitize_content(content, limits);
225 }
226
227 if let Some(source) = &mut entry.source {
228 sanitize_opt(&mut source.title, limits);
229 sanitize_opt(&mut source.rights, limits);
230 }
231 if let Some(itunes) = &mut entry.itunes {
232 sanitize_opt(&mut itunes.title, limits);
233 sanitize_opt(&mut itunes.subtitle, limits);
234 sanitize_opt(&mut itunes.summary, limits);
235 }
236}
237
238/// Sanitize a flat/detail field pair, fail-closed on the detail's declared type.
239///
240/// `TextType::Text` is the only type that skips sanitization. A missing detail
241/// (`None`) is treated the same as `Html`/`Xhtml`: the parser could not establish
242/// that the value is safe plain text, so it must not be trusted by default.
243fn sanitize_pair(
244 value: &mut Option<String>,
245 detail: &mut Option<TextConstruct>,
246 limits: &ParserLimits,
247) {
248 if matches!(
249 detail.as_ref().map(|d| d.content_type),
250 Some(TextType::Text)
251 ) {
252 return;
253 }
254 sanitize_opt(value, limits);
255 if let Some(detail) = detail {
256 detail.value = sanitize_html_bounded(&detail.value, limits.max_html_nesting_depth);
257 }
258}
259
260/// Sanitize a `Content` block, fail-closed on its declared MIME type.
261///
262/// Only an explicit `text/plain` type skips sanitization; a missing or
263/// unrecognized type (including `text/html`, `application/xhtml+xml`, and
264/// anything else) is sanitized.
265fn sanitize_content(content: &mut Content, limits: &ParserLimits) {
266 if content
267 .content_type
268 .as_deref()
269 .is_some_and(|t| t.eq_ignore_ascii_case(MimeType::TEXT_PLAIN))
270 {
271 return;
272 }
273 content.value = sanitize_html_bounded(&content.value, limits.max_html_nesting_depth);
274}
275
276/// Sanitize a plain string field that carries no type metadata.
277///
278/// These fields (`dc_rights`, `image.title`, iTunes free text, etc.) have no
279/// signal to indicate they are markup-free, so they are always sanitized.
280fn sanitize_opt(value: &mut Option<String>, limits: &ParserLimits) {
281 if let Some(v) = value {
282 *v = sanitize_html_bounded(v, limits.max_html_nesting_depth);
283 }
284}
285
286/// Sanitize HTML, falling back to plain-text escaping when the input is nested
287/// deeper than `max_depth`.
288///
289/// Ammonia's HTML5 tree builder exhibits quadratic-time behavior on
290/// pathologically deep tag nesting within a single text field (verified:
291/// hundreds-of-times slowdown on a single deeply nested `<div>` chain). Rather
292/// than feed such input to ammonia unbounded, it is instead escaped as plain
293/// text via `escape_html_plain`, which is O(n) regardless of nesting shape and
294/// still guarantees no markup survives (#438).
295fn sanitize_html_bounded(input: &str, max_depth: usize) -> String {
296 if html_nesting_exceeds(input, max_depth) {
297 escape_html_plain(input)
298 } else {
299 sanitize_html(input)
300 }
301}
302
303/// HTML void elements — self-closing by the HTML5 spec, never nest. A gallery
304/// of hundreds of `<img>`/`<br>` tags is not "deeply nested" and must not
305/// trip the depth bound.
306const VOID_ELEMENTS: &[&str] = &[
307 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
308 "track", "wbr",
309];
310
311/// Elements HTML5 implicitly closes when another instance of the same tag
312/// opens without an explicit end tag (e.g. `<li>a<li>b` auto-closes the first
313/// `<li>`). A long run of unclosed instances — common in real feed content
314/// (list items, table rows/cells) — must not accumulate depth.
315const AUTO_CLOSE_ELEMENTS: &[&str] = &["li", "option", "p", "td", "th", "tr"];
316
317/// Elements that act as a scope barrier per HTML5's "has an element in
318/// scope" algorithm: a closing tag cannot pop through one of these to reach
319/// a same-named ancestor further down the stack. `</div>` while a `<table>`
320/// is still open inside it does not close the div — the table blocks the
321/// search, exactly as html5ever's tree builder behaves, so both elements
322/// stay genuinely open (and genuinely nested) until the table itself closes.
323const SCOPE_BARRIERS: &[&str] = &[
324 "table", "td", "th", "caption", "object", "marquee", "applet",
325];
326
327/// HTML5's "formatting elements" (the list used by the tree builder's
328/// adoption-agency/reconstruction algorithm). Repeating one of these unclosed
329/// does not create the pathologically deep, expensive-to-sanitize DOM that
330/// repeating a structural element (`<div>`, `<table>`, `<section>`, ...)
331/// does — verified empirically during review: `<b>`/`<font>` repeated 40,000
332/// times unclosed sanitize in ~200ms (linear), while `<div>`/`<section>` at
333/// the same scale take 30+ seconds (quadratic). Excluded from the depth
334/// count for the same reason void elements are: they are not the hazard this
335/// guard exists to catch (#438).
336const FORMATTING_ELEMENTS: &[&str] = &[
337 "a", "b", "big", "code", "em", "font", "i", "nobr", "s", "small", "strike", "strong", "tt", "u",
338];
339
340/// A field containing more than this many tags is treated as pathologically
341/// nested regardless of what the depth stack reports. This is a cheap,
342/// purely additive O(1) backstop, independent of how closely the tag-name
343/// model above tracks html5ever's real semantics: legitimate feed content
344/// never needs anywhere close to 10,000 tags in a single field, so bounding
345/// total tag count here closes the whole class of "the depth heuristic
346/// diverges from html5ever in some as-yet-undiscovered way" bugs, not just
347/// the specific ones found so far (#438).
348const MAX_TAGS_PER_FIELD: usize = 10_000;
349
350/// Extract a tag's element name: the leading run of bytes up to the first
351/// ASCII whitespace or `/` (attributes, or the trailing `/` of `<br/>`).
352fn tag_name(inner: &[u8]) -> &[u8] {
353 let end = inner
354 .iter()
355 .position(|b| b.is_ascii_whitespace() || *b == b'/')
356 .unwrap_or(inner.len());
357 &inner[..end]
358}
359
360fn contains_name_ci(names: &[&str], name: &[u8]) -> bool {
361 names
362 .iter()
363 .any(|n| name.eq_ignore_ascii_case(n.as_bytes()))
364}
365
366/// Search the stack from the top for an element named `name`, stopping (and
367/// reporting no match) if a [`SCOPE_BARRIERS`] element is encountered first
368/// — mirrors html5ever's "has an element in scope" check.
369fn find_in_scope(stack: &[&[u8]], name: &[u8]) -> Option<usize> {
370 for (idx, tag) in stack.iter().enumerate().rev() {
371 if tag.eq_ignore_ascii_case(name) {
372 return Some(idx);
373 }
374 if contains_name_ci(SCOPE_BARRIERS, tag) {
375 return None;
376 }
377 }
378 None
379}
380
381/// Cheap, single-pass estimate of HTML open-tag nesting depth using a
382/// name-matched tag stack.
383///
384/// Intentionally approximate — it does not build a full DOM or validate the
385/// document, so it is not a substitute for ammonia's tree builder — but
386/// unlike a naive open/close counter, it tracks tag *names*, so it cannot be
387/// fooled by a run of mismatched closing tags (e.g. `("<div></x>")*n`, which
388/// never actually closes any `<div>`, real HTML5 parsers included: an end
389/// tag with no matching open element on the stack is simply ignored). It
390/// also recognizes HTML void elements (`<br>`, `<img>`, ...), auto-closing
391/// elements (`<li>`, `<p>`, ...), formatting elements (`<b>`, `<font>`, ...),
392/// and scope-barrier elements (`<table>`, `<td>`, ...) so ordinary valid
393/// HTML — image galleries, `<br>`-separated text, unclosed `<li>`/`<p>`
394/// runs, tables, runs of unclosed inline formatting — is never misjudged as
395/// pathologically nested, while genuinely deep nesting (including nesting
396/// hidden behind a scope barrier) is never missed. An O(1) total-tag-count
397/// backstop ([`MAX_TAGS_PER_FIELD`]) additionally bounds worst-case behavior
398/// independent of how well the rest of this model matches html5ever (#438).
399fn html_nesting_exceeds(html: &str, max_depth: usize) -> bool {
400 let bytes = html.as_bytes();
401 let mut stack: Vec<&[u8]> = Vec::new();
402 let mut tag_count: usize = 0;
403 let mut i = 0;
404 while i < bytes.len() {
405 if bytes[i] != b'<' {
406 i += 1;
407 continue;
408 }
409 let Some(rel_end) = bytes[i..].iter().position(|&b| b == b'>') else {
410 break; // no closing '>' in the remainder: no more complete tags to scan
411 };
412 let inner = &bytes[i + 1..i + rel_end];
413 i += rel_end + 1;
414
415 if inner.first() == Some(&b'!') || inner.first() == Some(&b'?') {
416 continue; // comment, doctype, or processing instruction: doesn't nest
417 }
418
419 tag_count += 1;
420 if tag_count > MAX_TAGS_PER_FIELD {
421 return true;
422 }
423
424 if inner.first() == Some(&b'/') {
425 // Closing tag: only pops a *matching* open element (and anything
426 // opened after it) that is actually in scope. An end tag with no
427 // match in scope is ignored, exactly like html5ever's tree
428 // builder — this is what keeps a run of bogus closing tags, or
429 // one blocked by a scope barrier, from masking real nesting.
430 let name = tag_name(&inner[1..]);
431 if let Some(pos) = find_in_scope(&stack, name) {
432 stack.truncate(pos);
433 }
434 continue;
435 }
436
437 let self_closing = inner.last() == Some(&b'/');
438 let name = tag_name(if self_closing {
439 &inner[..inner.len() - 1]
440 } else {
441 inner
442 });
443
444 if self_closing
445 || contains_name_ci(VOID_ELEMENTS, name)
446 || contains_name_ci(FORMATTING_ELEMENTS, name)
447 {
448 continue; // doesn't nest (or, for formatting elements, doesn't nest expensively)
449 }
450
451 if contains_name_ci(AUTO_CLOSE_ELEMENTS, name)
452 && let Some(pos) = find_in_scope(&stack, name)
453 {
454 stack.truncate(pos);
455 }
456
457 stack.push(name);
458 if stack.len() > max_depth {
459 return true;
460 }
461 }
462 false
463}
464
465/// Escape the characters that give HTML its structure, without parsing it.
466///
467/// O(n) regardless of input shape — the fallback used by `sanitize_html_bounded`
468/// when `html_nesting_exceeds` rejects input as too deep to safely hand to
469/// ammonia. No tag can survive this transform, so it is safe even though it
470/// does not attempt to preserve any formatting.
471fn escape_html_plain(input: &str) -> String {
472 let mut out = String::with_capacity(input.len());
473 for ch in input.chars() {
474 match ch {
475 '&' => out.push_str("&"),
476 '<' => out.push_str("<"),
477 '>' => out.push_str(">"),
478 '"' => out.push_str("""),
479 '\'' => out.push_str("'"),
480 _ => out.push(ch),
481 }
482 }
483 out
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn test_sanitize_removes_script() {
492 let html = r"<p>Hello</p><script>alert('XSS')</script>";
493 let clean = sanitize_html(html);
494 assert!(!clean.contains("script"));
495 assert!(clean.contains("Hello"));
496 }
497
498 #[test]
499 fn test_sanitize_allows_safe_tags() {
500 let html = r#"<p>Hello <b>world</b> <a href="http://example.com">link</a></p>"#;
501 let clean = sanitize_html(html);
502 assert!(clean.contains("<p>"));
503 assert!(clean.contains("<b>"));
504 assert!(clean.contains("<a"));
505 }
506
507 #[test]
508 fn test_sanitize_removes_onclick() {
509 let html = r#"<a href="/" onclick="alert('XSS')">Click</a>"#;
510 let clean = sanitize_html(html);
511 assert!(!clean.contains("onclick"));
512 assert!(clean.contains("href"));
513 }
514
515 #[test]
516 fn test_decode_entities() {
517 assert_eq!(decode_entities("<p>"), "<p>");
518 assert_eq!(decode_entities("&"), "&");
519 assert_eq!(decode_entities("""), "\"");
520 assert_eq!(decode_entities("'"), "'");
521 }
522
523 #[test]
524 fn test_decode_numeric_entities() {
525 assert_eq!(decode_entities("<"), "<");
526 assert_eq!(decode_entities("<"), "<");
527 }
528
529 #[test]
530 fn test_strip_tags() {
531 let html = "<p>Hello <b>world</b></p>";
532 assert_eq!(strip_tags(html), "Hello world");
533 }
534
535 #[test]
536 fn test_xss_img_onerror() {
537 let html = r#"<img src="x" onerror="alert('XSS')">"#;
538 let clean = sanitize_html(html);
539 assert!(!clean.contains("onerror"));
540 }
541
542 #[test]
543 fn test_xss_javascript_url() {
544 let html = r#"<a href="javascript:alert('XSS')">Click</a>"#;
545 let clean = sanitize_html(html);
546 assert!(!clean.contains("javascript:"));
547 }
548
549 #[test]
550 fn test_xss_iframe() {
551 let html = r#"<iframe src="http://evil.com"></iframe>"#;
552 let clean = sanitize_html(html);
553 assert!(!clean.contains("iframe"));
554 }
555
556 #[test]
557 fn test_xss_data_url() {
558 let html = r#"<a href="data:text/html,<script>alert('XSS')</script>">Click</a>"#;
559 let clean = sanitize_html(html);
560 assert!(!clean.contains("data:"));
561 }
562
563 #[test]
564 fn test_sanitize_empty_string() {
565 assert_eq!(sanitize_html(""), "");
566 }
567
568 #[test]
569 fn test_sanitize_plain_text() {
570 let text = "Plain text with no tags";
571 assert_eq!(sanitize_html(text), text);
572 }
573
574 #[test]
575 fn test_decode_entities_no_entities() {
576 let text = "No entities here";
577 assert_eq!(decode_entities(text), text);
578 }
579
580 #[test]
581 fn test_strip_tags_nested() {
582 let html = "<div><p>Hello <span><b>world</b></span></p></div>";
583 assert_eq!(strip_tags(html), "Hello world");
584 }
585
586 #[test]
587 fn test_sanitize_link_rel_attribute() {
588 let html = r#"<a href="http://example.com">Link</a>"#;
589 let clean = sanitize_html(html);
590 assert!(clean.contains("nofollow"));
591 assert!(clean.contains("noopener"));
592 assert!(clean.contains("noreferrer"));
593 }
594}