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.
114    ///
115    /// Default: 10 MB
116    pub max_text_length: usize,
117
118    /// Maximum total feed size in bytes
119    ///
120    /// The entire feed must fit within this limit.
121    ///
122    /// Default: 100 MB
123    pub max_feed_size_bytes: usize,
124
125    /// Maximum attribute value length in bytes
126    ///
127    /// XML attributes should be reasonably sized.
128    ///
129    /// Default: 64 KB
130    pub max_attribute_length: usize,
131
132    /// Maximum number of podcast soundbites per entry
133    ///
134    /// Podcast 2.0 soundbite elements for shareable clips.
135    ///
136    /// Default: 10 soundbites
137    pub max_podcast_soundbites: usize,
138
139    /// Maximum number of podcast transcripts per entry
140    ///
141    /// Podcast 2.0 transcript elements.
142    ///
143    /// Default: 20 transcripts
144    pub max_podcast_transcripts: usize,
145
146    /// Maximum number of podcast funding elements per feed
147    ///
148    /// Podcast 2.0 funding elements for donation links.
149    ///
150    /// Default: 20 funding elements
151    pub max_podcast_funding: usize,
152
153    /// Maximum number of podcast person elements per entry
154    ///
155    /// Podcast 2.0 person elements for hosts, guests, etc.
156    ///
157    /// Default: 50 persons
158    pub max_podcast_persons: usize,
159
160    /// Maximum number of podcast value recipients per feed
161    ///
162    /// Podcast 2.0 value recipients for payment splitting.
163    /// Prevents `DoS` from feeds with excessive recipient lists.
164    ///
165    /// Default: 20 recipients
166    pub max_value_recipients: usize,
167
168    /// Maximum number of alternate enclosures per entry
169    ///
170    /// Default: 20
171    pub max_podcast_alternate_enclosures: usize,
172
173    /// Maximum number of sources per alternate enclosure
174    ///
175    /// Default: 10
176    pub max_podcast_alternate_enclosure_sources: usize,
177
178    /// Maximum number of podroll entries per feed
179    ///
180    /// Default: 50
181    pub max_podcast_podroll: usize,
182
183    /// Maximum number of socialInteract elements per entry
184    ///
185    /// Default: 20
186    pub max_podcast_social_interact: usize,
187
188    /// Maximum number of txt records per feed or entry
189    ///
190    /// Default: 20
191    pub max_podcast_txt: usize,
192
193    /// Maximum number of follow links per feed or entry
194    ///
195    /// Default: 20
196    pub max_podcast_follow: usize,
197
198    /// Maximum number of chat references per feed or entry
199    ///
200    /// Podcast 2.0 chat elements pointing to chat rooms/servers.
201    ///
202    /// Default: 20
203    pub max_podcast_chat: usize,
204
205    /// Maximum number of value time splits per `podcast:value` element
206    ///
207    /// Podcast 2.0 valueTimeSplit elements for routing payments over time
208    /// ranges. Prevents `DoS` from feeds with excessive split lists.
209    ///
210    /// Default: 20
211    pub max_podcast_value_time_splits: usize,
212}
213
214impl Default for ParserLimits {
215    /// Creates default parser limits suitable for general use
216    ///
217    /// These defaults are conservative and should work for most feeds,
218    /// including large podcast feeds and news aggregators.
219    fn default() -> Self {
220        Self {
221            max_entries: 10_000,
222            max_links_per_feed: 100,
223            max_links_per_entry: 50,
224            max_authors: 20,
225            max_contributors: 20,
226            max_tags: 100,
227            max_content_blocks: 10,
228            max_enclosures: 20,
229            max_namespaces: 100,
230            max_nesting_depth: 100,
231            max_html_nesting_depth: 100,
232            max_text_length: 10 * 1024 * 1024,      // 10 MB
233            max_feed_size_bytes: 100 * 1024 * 1024, // 100 MB
234            max_attribute_length: 64 * 1024,        // 64 KB
235            max_podcast_soundbites: 10,
236            max_podcast_transcripts: 20,
237            max_podcast_funding: 20,
238            max_podcast_persons: 50,
239            max_value_recipients: 20,
240            max_podcast_alternate_enclosures: 20,
241            max_podcast_alternate_enclosure_sources: 10,
242            max_podcast_podroll: 50,
243            max_podcast_social_interact: 20,
244            max_podcast_txt: 20,
245            max_podcast_follow: 20,
246            max_podcast_chat: 20,
247            max_podcast_value_time_splits: 20,
248        }
249    }
250}
251
252impl ParserLimits {
253    /// Creates strict limits for resource-constrained environments
254    ///
255    /// Use this for embedded systems or when parsing untrusted feeds
256    /// with minimal resources.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use feedparser_rs::ParserLimits;
262    ///
263    /// let limits = ParserLimits::strict();
264    /// assert_eq!(limits.max_entries, 1_000);
265    /// ```
266    #[must_use]
267    pub const fn strict() -> Self {
268        Self {
269            max_entries: 1_000,
270            max_links_per_feed: 20,
271            max_links_per_entry: 10,
272            max_authors: 5,
273            max_contributors: 5,
274            max_tags: 20,
275            max_content_blocks: 3,
276            max_enclosures: 5,
277            max_namespaces: 20,
278            max_nesting_depth: 50,
279            max_html_nesting_depth: 50,
280            max_text_length: 1024 * 1024,          // 1 MB
281            max_feed_size_bytes: 10 * 1024 * 1024, // 10 MB
282            max_attribute_length: 8 * 1024,        // 8 KB
283            max_podcast_soundbites: 5,
284            max_podcast_transcripts: 5,
285            max_podcast_funding: 5,
286            max_podcast_persons: 10,
287            max_value_recipients: 5,
288            max_podcast_alternate_enclosures: 5,
289            max_podcast_alternate_enclosure_sources: 3,
290            max_podcast_podroll: 10,
291            max_podcast_social_interact: 5,
292            max_podcast_txt: 5,
293            max_podcast_follow: 5,
294            max_podcast_chat: 5,
295            max_podcast_value_time_splits: 5,
296        }
297    }
298
299    /// Creates permissive limits for trusted feeds
300    ///
301    /// Use this only for feeds from trusted sources where you expect
302    /// large data volumes (e.g., feed archives).
303    ///
304    /// # Examples
305    ///
306    /// ```
307    /// use feedparser_rs::ParserLimits;
308    ///
309    /// let limits = ParserLimits::permissive();
310    /// assert_eq!(limits.max_entries, 100_000);
311    /// ```
312    #[must_use]
313    pub const fn permissive() -> Self {
314        Self {
315            max_entries: 100_000,
316            max_links_per_feed: 500,
317            max_links_per_entry: 200,
318            max_authors: 100,
319            max_contributors: 100,
320            max_tags: 500,
321            max_content_blocks: 50,
322            max_enclosures: 100,
323            max_namespaces: 500,
324            max_nesting_depth: 200,
325            max_html_nesting_depth: 200,
326            max_text_length: 50 * 1024 * 1024,      // 50 MB
327            max_feed_size_bytes: 500 * 1024 * 1024, // 500 MB
328            max_attribute_length: 256 * 1024,       // 256 KB
329            max_podcast_soundbites: 50,
330            max_podcast_transcripts: 100,
331            max_podcast_funding: 50,
332            max_podcast_persons: 200,
333            max_value_recipients: 50,
334            max_podcast_alternate_enclosures: 100,
335            max_podcast_alternate_enclosure_sources: 50,
336            max_podcast_podroll: 200,
337            max_podcast_social_interact: 100,
338            max_podcast_txt: 100,
339            max_podcast_follow: 100,
340            max_podcast_chat: 100,
341            max_podcast_value_time_splits: 50,
342        }
343    }
344
345    /// Validates that a feed size is within limits
346    ///
347    /// Call this before starting to parse a feed.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error if the feed exceeds `max_feed_size_bytes`.
352    pub const fn check_feed_size(&self, size: usize) -> Result<(), LimitError> {
353        if size > self.max_feed_size_bytes {
354            Err(LimitError::FeedTooLarge {
355                size,
356                max: self.max_feed_size_bytes,
357            })
358        } else {
359            Ok(())
360        }
361    }
362
363    /// Validates that a collection size is within limits
364    ///
365    /// Use this during parsing to check collection sizes.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the collection size exceeds the specified limit.
370    pub const fn check_collection_size(
371        &self,
372        current: usize,
373        limit: usize,
374        name: &'static str,
375    ) -> Result<(), LimitError> {
376        if current >= limit {
377            Err(LimitError::CollectionTooLarge {
378                name,
379                size: current,
380                max: limit,
381            })
382        } else {
383            Ok(())
384        }
385    }
386
387    /// Validates XML nesting depth
388    ///
389    /// # Errors
390    ///
391    /// Returns an error if nesting depth exceeds `max_nesting_depth`.
392    pub const fn check_nesting_depth(&self, depth: usize) -> Result<(), LimitError> {
393        if depth > self.max_nesting_depth {
394            Err(LimitError::NestingTooDeep {
395                depth,
396                max: self.max_nesting_depth,
397            })
398        } else {
399            Ok(())
400        }
401    }
402
403    /// Validates text field length
404    ///
405    /// # Errors
406    ///
407    /// Returns an error if text length exceeds `max_text_length`.
408    pub const fn check_text_length(&self, length: usize) -> Result<(), LimitError> {
409        if length > self.max_text_length {
410            Err(LimitError::TextTooLong {
411                length,
412                max: self.max_text_length,
413            })
414        } else {
415            Ok(())
416        }
417    }
418}
419
420/// Errors that occur when parser limits are exceeded
421#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
422#[allow(missing_docs)] // Fields are self-explanatory from error messages
423pub enum LimitError {
424    /// Feed size exceeds maximum allowed
425    #[error("Feed size ({size} bytes) exceeds maximum ({max} bytes)")]
426    FeedTooLarge { size: usize, max: usize },
427
428    /// Collection (entries, links, etc.) has too many items
429    #[error("Collection '{name}' has {size} items, exceeds maximum ({max})")]
430    CollectionTooLarge {
431        name: &'static str,
432        size: usize,
433        max: usize,
434    },
435
436    /// XML nesting is too deep
437    #[error("XML nesting depth ({depth}) exceeds maximum ({max})")]
438    NestingTooDeep { depth: usize, max: usize },
439
440    /// Text field is too long
441    #[error("Text field length ({length} bytes) exceeds maximum ({max} bytes)")]
442    TextTooLong { length: usize, max: usize },
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_default_limits() {
451        let limits = ParserLimits::default();
452        assert_eq!(limits.max_entries, 10_000);
453        assert_eq!(limits.max_feed_size_bytes, 100 * 1024 * 1024);
454    }
455
456    #[test]
457    fn test_strict_limits() {
458        let limits = ParserLimits::strict();
459        assert_eq!(limits.max_entries, 1_000);
460        assert!(limits.max_entries < ParserLimits::default().max_entries);
461    }
462
463    #[test]
464    fn test_permissive_limits() {
465        let limits = ParserLimits::permissive();
466        assert_eq!(limits.max_entries, 100_000);
467        assert!(limits.max_entries > ParserLimits::default().max_entries);
468    }
469
470    #[test]
471    fn test_check_feed_size_ok() {
472        let limits = ParserLimits::default();
473        assert!(limits.check_feed_size(1024).is_ok());
474    }
475
476    #[test]
477    fn test_check_feed_size_too_large() {
478        let limits = ParserLimits::default();
479        let result = limits.check_feed_size(200 * 1024 * 1024);
480        assert!(result.is_err());
481        assert!(matches!(result, Err(LimitError::FeedTooLarge { .. })));
482    }
483
484    #[test]
485    fn test_check_collection_size_ok() {
486        let limits = ParserLimits::default();
487        assert!(
488            limits
489                .check_collection_size(50, limits.max_entries, "entries")
490                .is_ok()
491        );
492    }
493
494    #[test]
495    fn test_check_collection_size_too_large() {
496        let limits = ParserLimits::default();
497        let result = limits.check_collection_size(10_001, limits.max_entries, "entries");
498        assert!(result.is_err());
499        assert!(matches!(result, Err(LimitError::CollectionTooLarge { .. })));
500    }
501
502    #[test]
503    fn test_check_nesting_depth_ok() {
504        let limits = ParserLimits::default();
505        assert!(limits.check_nesting_depth(50).is_ok());
506    }
507
508    #[test]
509    fn test_check_nesting_depth_too_deep() {
510        let limits = ParserLimits::default();
511        let result = limits.check_nesting_depth(101);
512        assert!(result.is_err());
513        assert!(matches!(result, Err(LimitError::NestingTooDeep { .. })));
514    }
515
516    #[test]
517    fn test_check_text_length_ok() {
518        let limits = ParserLimits::default();
519        assert!(limits.check_text_length(1024).is_ok());
520    }
521
522    #[test]
523    fn test_check_text_length_too_long() {
524        let limits = ParserLimits::default();
525        let result = limits.check_text_length(20 * 1024 * 1024);
526        assert!(result.is_err());
527        assert!(matches!(result, Err(LimitError::TextTooLong { .. })));
528    }
529
530    #[test]
531    fn test_limit_error_display() {
532        let err = LimitError::FeedTooLarge {
533            size: 200_000_000,
534            max: 100_000_000,
535        };
536        let msg = err.to_string();
537        assert!(msg.contains("200000000"));
538        assert!(msg.contains("100000000"));
539    }
540
541    #[test]
542    fn test_max_value_recipients_default() {
543        let limits = ParserLimits::default();
544        assert_eq!(limits.max_value_recipients, 20);
545    }
546
547    #[test]
548    fn test_max_value_recipients_strict() {
549        let limits = ParserLimits::strict();
550        assert_eq!(limits.max_value_recipients, 5);
551        assert!(limits.max_value_recipients < ParserLimits::default().max_value_recipients);
552    }
553
554    #[test]
555    fn test_max_value_recipients_permissive() {
556        let limits = ParserLimits::permissive();
557        assert_eq!(limits.max_value_recipients, 50);
558        assert!(limits.max_value_recipients > ParserLimits::default().max_value_recipients);
559    }
560
561    #[test]
562    fn test_value_recipients_limit_enforcement() {
563        let limits = ParserLimits::default();
564
565        // Within limit
566        assert!(
567            limits
568                .check_collection_size(19, limits.max_value_recipients, "value_recipients")
569                .is_ok()
570        );
571
572        // At limit
573        assert!(
574            limits
575                .check_collection_size(20, limits.max_value_recipients, "value_recipients")
576                .is_err()
577        );
578
579        // Exceeds limit
580        let result =
581            limits.check_collection_size(21, limits.max_value_recipients, "value_recipients");
582        assert!(result.is_err());
583        assert!(matches!(result, Err(LimitError::CollectionTooLarge { .. })));
584    }
585
586    #[test]
587    fn test_max_podcast_chat_tiers() {
588        assert_eq!(ParserLimits::default().max_podcast_chat, 20);
589        assert_eq!(ParserLimits::strict().max_podcast_chat, 5);
590        assert_eq!(ParserLimits::permissive().max_podcast_chat, 100);
591        assert!(ParserLimits::strict().max_podcast_chat < ParserLimits::default().max_podcast_chat);
592        assert!(
593            ParserLimits::permissive().max_podcast_chat > ParserLimits::default().max_podcast_chat
594        );
595    }
596
597    #[test]
598    fn test_podcast_chat_limit_enforcement() {
599        let limits = ParserLimits::default();
600        assert!(
601            limits
602                .check_collection_size(19, limits.max_podcast_chat, "chat")
603                .is_ok()
604        );
605        let result = limits.check_collection_size(20, limits.max_podcast_chat, "chat");
606        assert!(result.is_err());
607        assert!(matches!(result, Err(LimitError::CollectionTooLarge { .. })));
608    }
609
610    #[test]
611    fn test_max_podcast_value_time_splits_tiers() {
612        assert_eq!(ParserLimits::default().max_podcast_value_time_splits, 20);
613        assert_eq!(ParserLimits::strict().max_podcast_value_time_splits, 5);
614        assert_eq!(ParserLimits::permissive().max_podcast_value_time_splits, 50);
615        assert!(
616            ParserLimits::strict().max_podcast_value_time_splits
617                < ParserLimits::default().max_podcast_value_time_splits
618        );
619        assert!(
620            ParserLimits::permissive().max_podcast_value_time_splits
621                > ParserLimits::default().max_podcast_value_time_splits
622        );
623    }
624
625    #[test]
626    fn test_podcast_value_time_splits_limit_enforcement() {
627        let limits = ParserLimits::default();
628        assert!(
629            limits
630                .check_collection_size(19, limits.max_podcast_value_time_splits, "time_splits")
631                .is_ok()
632        );
633        let result =
634            limits.check_collection_size(20, limits.max_podcast_value_time_splits, "time_splits");
635        assert!(result.is_err());
636        assert!(matches!(result, Err(LimitError::CollectionTooLarge { .. })));
637    }
638}