Skip to main content

feedparser_rs/
limits.rs

1//! Parser limits to prevent `DoS` attacks and excessive memory usage
2
3/// Parser limits for protecting against denial-of-service attacks
4///
5/// These limits prevent malicious or malformed feeds from causing excessive
6/// memory allocation, deep recursion, or other resource exhaustion issues.
7///
8/// # Examples
9///
10/// ```
11/// use feedparser_rs::ParserLimits;
12///
13/// let limits = ParserLimits::default();
14/// assert_eq!(limits.max_entries, 10_000);
15///
16/// // Custom limits for restricted environments
17/// let strict = ParserLimits {
18///     max_entries: 1_000,
19///     max_feed_size_bytes: 10 * 1024 * 1024, // 10MB
20///     ..Default::default()
21/// };
22/// ```
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct ParserLimits {
25    /// Maximum number of entries/items in a feed
26    ///
27    /// Prevents memory exhaustion from feeds with millions of items.
28    /// Typical feeds have 10-100 entries, large feeds may have up to 1000.
29    ///
30    /// Default: 10,000 entries
31    pub max_entries: usize,
32
33    /// Maximum number of links per feed (channel-level)
34    ///
35    /// Prevents link bombing attacks.
36    ///
37    /// Default: 100 links
38    pub max_links_per_feed: usize,
39
40    /// Maximum number of links per entry
41    ///
42    /// Prevents link bombing in individual entries.
43    ///
44    /// Default: 50 links
45    pub max_links_per_entry: usize,
46
47    /// Maximum number of authors per feed or entry
48    ///
49    /// Default: 20 authors
50    pub max_authors: usize,
51
52    /// Maximum number of contributors per feed or entry
53    ///
54    /// Default: 20 contributors
55    pub max_contributors: usize,
56
57    /// Maximum number of tags/categories per feed or entry
58    ///
59    /// Default: 100 tags
60    pub max_tags: usize,
61
62    /// Maximum number of content blocks per entry
63    ///
64    /// Atom feeds can have multiple content elements.
65    ///
66    /// Default: 10 content blocks
67    pub max_content_blocks: usize,
68
69    /// Maximum number of enclosures per entry
70    ///
71    /// Podcast feeds typically have 1 enclosure per episode.
72    ///
73    /// Default: 20 enclosures
74    pub max_enclosures: usize,
75
76    /// Maximum number of XML namespaces
77    ///
78    /// Prevents namespace pollution attacks.
79    ///
80    /// Default: 100 namespaces
81    pub max_namespaces: usize,
82
83    /// Maximum XML nesting depth
84    ///
85    /// Prevents stack overflow from deeply nested XML.
86    ///
87    /// Default: 100 levels
88    pub max_nesting_depth: usize,
89
90    /// Maximum HTML tag nesting depth accepted by HTML sanitization
91    ///
92    /// `sanitize_html`'s underlying HTML5 tree builder exhibits quadratic-time
93    /// behavior on pathologically deep tag nesting within a single text field
94    /// (verified: multi-second slowdown from a single deeply nested `<div>`
95    /// chain). Content nested deeper than this is escaped as plain text
96    /// (`&lt;`/`&gt;`) instead of being run through the sanitizer, bounding
97    /// worst-case CPU cost to linear regardless of input shape.
98    ///
99    /// This is a safety bound, not a compatibility ceiling to be tuned up to
100    /// match "typical" content: exceeding it degrades gracefully (the field's
101    /// HTML formatting is lost, but its text content is fully preserved and
102    /// still safely escaped) rather than dropping data or erroring, so it is
103    /// safe to keep conservative. Legitimate feed content — even unusually
104    /// deep block-quoted reply chains or nested tables — rarely exceeds a few
105    /// dozen levels; 100 (50 under [`ParserLimits::strict`]) leaves headroom
106    /// above that without leaving much headroom for abuse.
107    ///
108    /// Default: 100 levels
109    pub max_html_nesting_depth: usize,
110
111    /// Maximum text field length in bytes
112    ///
113    /// Prevents excessive memory from huge title/description fields. Also bounds
114    /// the per-value serialized size of a captured JSON Feed extension object
115    /// (see `max_json_extensions`); an oversized extension value is dropped
116    /// entirely rather than truncated.
117    ///
118    /// Default: 10 MB
119    pub max_text_length: usize,
120
121    /// Maximum total feed size in bytes
122    ///
123    /// The entire feed must fit within this limit.
124    ///
125    /// Default: 100 MB
126    pub max_feed_size_bytes: usize,
127
128    /// Maximum attribute value length in bytes
129    ///
130    /// XML attributes should be reasonably sized.
131    ///
132    /// Default: 64 KB
133    pub max_attribute_length: usize,
134
135    /// Maximum number of podcast soundbites per entry
136    ///
137    /// Podcast 2.0 soundbite elements for shareable clips.
138    ///
139    /// Default: 10 soundbites
140    pub max_podcast_soundbites: usize,
141
142    /// Maximum number of podcast transcripts per entry
143    ///
144    /// Podcast 2.0 transcript elements.
145    ///
146    /// Default: 20 transcripts
147    pub max_podcast_transcripts: usize,
148
149    /// Maximum number of podcast funding elements per feed
150    ///
151    /// Podcast 2.0 funding elements for donation links.
152    ///
153    /// Default: 20 funding elements
154    pub max_podcast_funding: usize,
155
156    /// Maximum number of podcast person elements per entry
157    ///
158    /// Podcast 2.0 person elements for hosts, guests, etc.
159    ///
160    /// Default: 50 persons
161    pub max_podcast_persons: usize,
162
163    /// Maximum number of podcast value recipients per feed
164    ///
165    /// Podcast 2.0 value recipients for payment splitting.
166    /// Prevents `DoS` from feeds with excessive recipient lists.
167    ///
168    /// Default: 20 recipients
169    pub max_value_recipients: usize,
170
171    /// Maximum number of alternate enclosures per entry
172    ///
173    /// Default: 20
174    pub max_podcast_alternate_enclosures: usize,
175
176    /// Maximum number of sources per alternate enclosure
177    ///
178    /// Default: 10
179    pub max_podcast_alternate_enclosure_sources: usize,
180
181    /// Maximum number of podroll entries per feed
182    ///
183    /// Default: 50
184    pub max_podcast_podroll: usize,
185
186    /// Maximum number of socialInteract elements per entry
187    ///
188    /// Default: 20
189    pub max_podcast_social_interact: usize,
190
191    /// Maximum number of txt records per feed or entry
192    ///
193    /// Default: 20
194    pub max_podcast_txt: usize,
195
196    /// Maximum number of follow links per feed or entry
197    ///
198    /// Default: 20
199    pub max_podcast_follow: usize,
200
201    /// Maximum number of chat references per feed or entry
202    ///
203    /// Podcast 2.0 chat elements pointing to chat rooms/servers.
204    ///
205    /// Default: 20
206    pub max_podcast_chat: usize,
207
208    /// Maximum number of value time splits per `podcast:value` element
209    ///
210    /// Podcast 2.0 valueTimeSplit elements for routing payments over time
211    /// ranges. Prevents `DoS` from feeds with excessive split lists.
212    ///
213    /// Default: 20
214    pub max_podcast_value_time_splits: usize,
215
216    /// Maximum number of JSON Feed extension objects captured per scope
217    ///
218    /// JSON Feed permits custom object keys (starting with `_` followed by a
219    /// letter) anywhere in a feed; this bounds how many distinct keys are
220    /// captured per feed-root or item-root scope, counted independently. When a
221    /// scope has more extension keys than this limit, the surviving keys are the
222    /// alphabetically-first ones, not the document-order-first ones: `serde_json`
223    /// backs a JSON object with a `BTreeMap` by default (the `preserve_order`
224    /// feature, which would switch it to an `IndexMap` and make truncation
225    /// document-order-first, is not enabled).
226    ///
227    /// Default: 100
228    pub max_json_extensions: usize,
229}
230
231impl Default for ParserLimits {
232    /// Creates default parser limits suitable for general use
233    ///
234    /// These defaults are conservative and should work for most feeds,
235    /// including large podcast feeds and news aggregators.
236    fn default() -> Self {
237        Self {
238            max_entries: 10_000,
239            max_links_per_feed: 100,
240            max_links_per_entry: 50,
241            max_authors: 20,
242            max_contributors: 20,
243            max_tags: 100,
244            max_content_blocks: 10,
245            max_enclosures: 20,
246            max_namespaces: 100,
247            max_nesting_depth: 100,
248            max_html_nesting_depth: 100,
249            max_text_length: 10 * 1024 * 1024,      // 10 MB
250            max_feed_size_bytes: 100 * 1024 * 1024, // 100 MB
251            max_attribute_length: 64 * 1024,        // 64 KB
252            max_podcast_soundbites: 10,
253            max_podcast_transcripts: 20,
254            max_podcast_funding: 20,
255            max_podcast_persons: 50,
256            max_value_recipients: 20,
257            max_podcast_alternate_enclosures: 20,
258            max_podcast_alternate_enclosure_sources: 10,
259            max_podcast_podroll: 50,
260            max_podcast_social_interact: 20,
261            max_podcast_txt: 20,
262            max_podcast_follow: 20,
263            max_podcast_chat: 20,
264            max_podcast_value_time_splits: 20,
265            max_json_extensions: 100,
266        }
267    }
268}
269
270impl ParserLimits {
271    /// Creates strict limits for resource-constrained environments
272    ///
273    /// Use this for embedded systems or when parsing untrusted feeds
274    /// with minimal resources.
275    ///
276    /// # Examples
277    ///
278    /// ```
279    /// use feedparser_rs::ParserLimits;
280    ///
281    /// let limits = ParserLimits::strict();
282    /// assert_eq!(limits.max_entries, 1_000);
283    /// ```
284    #[must_use]
285    pub const fn strict() -> Self {
286        Self {
287            max_entries: 1_000,
288            max_links_per_feed: 20,
289            max_links_per_entry: 10,
290            max_authors: 5,
291            max_contributors: 5,
292            max_tags: 20,
293            max_content_blocks: 3,
294            max_enclosures: 5,
295            max_namespaces: 20,
296            max_nesting_depth: 50,
297            max_html_nesting_depth: 50,
298            max_text_length: 1024 * 1024,          // 1 MB
299            max_feed_size_bytes: 10 * 1024 * 1024, // 10 MB
300            max_attribute_length: 8 * 1024,        // 8 KB
301            max_podcast_soundbites: 5,
302            max_podcast_transcripts: 5,
303            max_podcast_funding: 5,
304            max_podcast_persons: 10,
305            max_value_recipients: 5,
306            max_podcast_alternate_enclosures: 5,
307            max_podcast_alternate_enclosure_sources: 3,
308            max_podcast_podroll: 10,
309            max_podcast_social_interact: 5,
310            max_podcast_txt: 5,
311            max_podcast_follow: 5,
312            max_podcast_chat: 5,
313            max_podcast_value_time_splits: 5,
314            max_json_extensions: 20,
315        }
316    }
317
318    /// Creates permissive limits for trusted feeds
319    ///
320    /// Use this only for feeds from trusted sources where you expect
321    /// large data volumes (e.g., feed archives).
322    ///
323    /// # Examples
324    ///
325    /// ```
326    /// use feedparser_rs::ParserLimits;
327    ///
328    /// let limits = ParserLimits::permissive();
329    /// assert_eq!(limits.max_entries, 100_000);
330    /// ```
331    #[must_use]
332    pub const fn permissive() -> Self {
333        Self {
334            max_entries: 100_000,
335            max_links_per_feed: 500,
336            max_links_per_entry: 200,
337            max_authors: 100,
338            max_contributors: 100,
339            max_tags: 500,
340            max_content_blocks: 50,
341            max_enclosures: 100,
342            max_namespaces: 500,
343            max_nesting_depth: 200,
344            max_html_nesting_depth: 200,
345            max_text_length: 50 * 1024 * 1024,      // 50 MB
346            max_feed_size_bytes: 500 * 1024 * 1024, // 500 MB
347            max_attribute_length: 256 * 1024,       // 256 KB
348            max_podcast_soundbites: 50,
349            max_podcast_transcripts: 100,
350            max_podcast_funding: 50,
351            max_podcast_persons: 200,
352            max_value_recipients: 50,
353            max_podcast_alternate_enclosures: 100,
354            max_podcast_alternate_enclosure_sources: 50,
355            max_podcast_podroll: 200,
356            max_podcast_social_interact: 100,
357            max_podcast_txt: 100,
358            max_podcast_follow: 100,
359            max_podcast_chat: 100,
360            max_podcast_value_time_splits: 50,
361            max_json_extensions: 500,
362        }
363    }
364
365    /// Validates that a feed size is within limits
366    ///
367    /// Call this before starting to parse a feed.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if the feed exceeds `max_feed_size_bytes`.
372    pub const fn check_feed_size(&self, size: usize) -> Result<(), LimitError> {
373        if size > self.max_feed_size_bytes {
374            Err(LimitError::FeedTooLarge {
375                size,
376                max: self.max_feed_size_bytes,
377            })
378        } else {
379            Ok(())
380        }
381    }
382
383    /// Validates that a collection size is within limits
384    ///
385    /// Use this during parsing to check collection sizes.
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if the collection size exceeds the specified limit.
390    pub const fn check_collection_size(
391        &self,
392        current: usize,
393        limit: usize,
394        name: &'static str,
395    ) -> Result<(), LimitError> {
396        if current >= limit {
397            Err(LimitError::CollectionTooLarge {
398                name,
399                size: current,
400                max: limit,
401            })
402        } else {
403            Ok(())
404        }
405    }
406
407    /// Validates XML nesting depth
408    ///
409    /// # Errors
410    ///
411    /// Returns an error if nesting depth exceeds `max_nesting_depth`.
412    pub const fn check_nesting_depth(&self, depth: usize) -> Result<(), LimitError> {
413        if depth > self.max_nesting_depth {
414            Err(LimitError::NestingTooDeep {
415                depth,
416                max: self.max_nesting_depth,
417            })
418        } else {
419            Ok(())
420        }
421    }
422
423    /// Validates text field length
424    ///
425    /// # Errors
426    ///
427    /// Returns an error if text length exceeds `max_text_length`.
428    pub const fn check_text_length(&self, length: usize) -> Result<(), LimitError> {
429        if length > self.max_text_length {
430            Err(LimitError::TextTooLong {
431                length,
432                max: self.max_text_length,
433            })
434        } else {
435            Ok(())
436        }
437    }
438}
439
440/// Errors that occur when parser limits are exceeded
441#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
442#[allow(missing_docs)] // Fields are self-explanatory from error messages
443pub enum LimitError {
444    /// Feed size exceeds maximum allowed
445    #[error("Feed size ({size} bytes) exceeds maximum ({max} bytes)")]
446    FeedTooLarge { size: usize, max: usize },
447
448    /// Collection (entries, links, etc.) has too many items
449    #[error("Collection '{name}' has {size} items, exceeds maximum ({max})")]
450    CollectionTooLarge {
451        name: &'static str,
452        size: usize,
453        max: usize,
454    },
455
456    /// XML nesting is too deep
457    #[error("XML nesting depth ({depth}) exceeds maximum ({max})")]
458    NestingTooDeep { depth: usize, max: usize },
459
460    /// Text field is too long
461    #[error("Text field length ({length} bytes) exceeds maximum ({max} bytes)")]
462    TextTooLong { length: usize, max: usize },
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_default_limits() {
471        let limits = ParserLimits::default();
472        assert_eq!(limits.max_entries, 10_000);
473        assert_eq!(limits.max_feed_size_bytes, 100 * 1024 * 1024);
474    }
475
476    #[test]
477    fn test_strict_limits() {
478        let limits = ParserLimits::strict();
479        assert_eq!(limits.max_entries, 1_000);
480        assert!(limits.max_entries < ParserLimits::default().max_entries);
481    }
482
483    #[test]
484    fn test_permissive_limits() {
485        let limits = ParserLimits::permissive();
486        assert_eq!(limits.max_entries, 100_000);
487        assert!(limits.max_entries > ParserLimits::default().max_entries);
488    }
489
490    #[test]
491    fn test_check_feed_size_ok() {
492        let limits = ParserLimits::default();
493        assert!(limits.check_feed_size(1024).is_ok());
494    }
495
496    #[test]
497    fn test_check_feed_size_too_large() {
498        let limits = ParserLimits::default();
499        let result = limits.check_feed_size(200 * 1024 * 1024);
500        assert!(result.is_err());
501        assert!(matches!(result, Err(LimitError::FeedTooLarge { .. })));
502    }
503
504    #[test]
505    fn test_check_collection_size_ok() {
506        let limits = ParserLimits::default();
507        assert!(
508            limits
509                .check_collection_size(50, limits.max_entries, "entries")
510                .is_ok()
511        );
512    }
513
514    #[test]
515    fn test_check_collection_size_too_large() {
516        let limits = ParserLimits::default();
517        let result = limits.check_collection_size(10_001, limits.max_entries, "entries");
518        assert!(result.is_err());
519        assert!(matches!(result, Err(LimitError::CollectionTooLarge { .. })));
520    }
521
522    #[test]
523    fn test_check_nesting_depth_ok() {
524        let limits = ParserLimits::default();
525        assert!(limits.check_nesting_depth(50).is_ok());
526    }
527
528    #[test]
529    fn test_check_nesting_depth_too_deep() {
530        let limits = ParserLimits::default();
531        let result = limits.check_nesting_depth(101);
532        assert!(result.is_err());
533        assert!(matches!(result, Err(LimitError::NestingTooDeep { .. })));
534    }
535
536    #[test]
537    fn test_check_text_length_ok() {
538        let limits = ParserLimits::default();
539        assert!(limits.check_text_length(1024).is_ok());
540    }
541
542    #[test]
543    fn test_check_text_length_too_long() {
544        let limits = ParserLimits::default();
545        let result = limits.check_text_length(20 * 1024 * 1024);
546        assert!(result.is_err());
547        assert!(matches!(result, Err(LimitError::TextTooLong { .. })));
548    }
549
550    #[test]
551    fn test_limit_error_display() {
552        let err = LimitError::FeedTooLarge {
553            size: 200_000_000,
554            max: 100_000_000,
555        };
556        let msg = err.to_string();
557        assert!(msg.contains("200000000"));
558        assert!(msg.contains("100000000"));
559    }
560
561    #[test]
562    fn test_max_value_recipients_default() {
563        let limits = ParserLimits::default();
564        assert_eq!(limits.max_value_recipients, 20);
565    }
566
567    #[test]
568    fn test_max_value_recipients_strict() {
569        let limits = ParserLimits::strict();
570        assert_eq!(limits.max_value_recipients, 5);
571        assert!(limits.max_value_recipients < ParserLimits::default().max_value_recipients);
572    }
573
574    #[test]
575    fn test_max_value_recipients_permissive() {
576        let limits = ParserLimits::permissive();
577        assert_eq!(limits.max_value_recipients, 50);
578        assert!(limits.max_value_recipients > ParserLimits::default().max_value_recipients);
579    }
580
581    #[test]
582    fn test_value_recipients_limit_enforcement() {
583        let limits = ParserLimits::default();
584
585        // Within limit
586        assert!(
587            limits
588                .check_collection_size(19, limits.max_value_recipients, "value_recipients")
589                .is_ok()
590        );
591
592        // At limit
593        assert!(
594            limits
595                .check_collection_size(20, limits.max_value_recipients, "value_recipients")
596                .is_err()
597        );
598
599        // Exceeds limit
600        let result =
601            limits.check_collection_size(21, limits.max_value_recipients, "value_recipients");
602        assert!(result.is_err());
603        assert!(matches!(result, Err(LimitError::CollectionTooLarge { .. })));
604    }
605
606    #[test]
607    fn test_max_podcast_chat_tiers() {
608        assert_eq!(ParserLimits::default().max_podcast_chat, 20);
609        assert_eq!(ParserLimits::strict().max_podcast_chat, 5);
610        assert_eq!(ParserLimits::permissive().max_podcast_chat, 100);
611        assert!(ParserLimits::strict().max_podcast_chat < ParserLimits::default().max_podcast_chat);
612        assert!(
613            ParserLimits::permissive().max_podcast_chat > ParserLimits::default().max_podcast_chat
614        );
615    }
616
617    #[test]
618    fn test_podcast_chat_limit_enforcement() {
619        let limits = ParserLimits::default();
620        assert!(
621            limits
622                .check_collection_size(19, limits.max_podcast_chat, "chat")
623                .is_ok()
624        );
625        let result = limits.check_collection_size(20, limits.max_podcast_chat, "chat");
626        assert!(result.is_err());
627        assert!(matches!(result, Err(LimitError::CollectionTooLarge { .. })));
628    }
629
630    #[test]
631    fn test_max_podcast_value_time_splits_tiers() {
632        assert_eq!(ParserLimits::default().max_podcast_value_time_splits, 20);
633        assert_eq!(ParserLimits::strict().max_podcast_value_time_splits, 5);
634        assert_eq!(ParserLimits::permissive().max_podcast_value_time_splits, 50);
635        assert!(
636            ParserLimits::strict().max_podcast_value_time_splits
637                < ParserLimits::default().max_podcast_value_time_splits
638        );
639        assert!(
640            ParserLimits::permissive().max_podcast_value_time_splits
641                > ParserLimits::default().max_podcast_value_time_splits
642        );
643    }
644
645    #[test]
646    fn test_podcast_value_time_splits_limit_enforcement() {
647        let limits = ParserLimits::default();
648        assert!(
649            limits
650                .check_collection_size(19, limits.max_podcast_value_time_splits, "time_splits")
651                .is_ok()
652        );
653        let result =
654            limits.check_collection_size(20, limits.max_podcast_value_time_splits, "time_splits");
655        assert!(result.is_err());
656        assert!(matches!(result, Err(LimitError::CollectionTooLarge { .. })));
657    }
658
659    #[test]
660    fn test_max_json_extensions_tiers() {
661        assert_eq!(ParserLimits::default().max_json_extensions, 100);
662        assert_eq!(ParserLimits::strict().max_json_extensions, 20);
663        assert_eq!(ParserLimits::permissive().max_json_extensions, 500);
664        assert!(
665            ParserLimits::strict().max_json_extensions
666                < ParserLimits::default().max_json_extensions
667        );
668        assert!(
669            ParserLimits::permissive().max_json_extensions
670                > ParserLimits::default().max_json_extensions
671        );
672    }
673}