Skip to main content

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("&lt;p&gt;Hello&lt;/p&gt;"), "<p>Hello</p>");
134/// assert_eq!(decode_entities("&amp;amp;"), "&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>&lt;script&gt;alert(1)&lt;/script&gt;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("&amp;"),
476            '<' => out.push_str("&lt;"),
477            '>' => out.push_str("&gt;"),
478            '"' => out.push_str("&quot;"),
479            '\'' => out.push_str("&#x27;"),
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("&lt;p&gt;"), "<p>");
518        assert_eq!(decode_entities("&amp;"), "&");
519        assert_eq!(decode_entities("&quot;"), "\"");
520        assert_eq!(decode_entities("&#39;"), "'");
521    }
522
523    #[test]
524    fn test_decode_numeric_entities() {
525        assert_eq!(decode_entities("&#60;"), "<");
526        assert_eq!(decode_entities("&#x3C;"), "<");
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}