Skip to main content

feedparser_rs/util/
base_url.rs

1//! Base URL resolution for xml:base support
2//!
3//! This module provides URL resolution following RFC 3986, supporting
4//! the `xml:base` attribute used in Atom and some RSS feeds.
5
6use super::ssrf;
7use url::Url;
8
9/// Validates that a URL is safe for external use (no SSRF risks)
10///
11/// This function checks for common SSRF attack vectors including:
12/// - Non-HTTP(S) schemes (file://, data://, etc.)
13/// - Localhost addresses (127.0.0.1, `::1`, localhost)
14/// - Private IP ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
15/// - Cloud metadata endpoints (169.254.169.254)
16///
17/// Delegates to the same [`crate::http::validation::validate_url`] rule set used
18/// by the HTTP fetcher, so `xml:base`-derived links and fetched feed URLs are
19/// held to identical SSRF protections.
20///
21/// # Arguments
22///
23/// * `url` - The URL to validate
24///
25/// # Returns
26///
27/// `true` if the URL is safe to use, `false` if it poses SSRF risks
28///
29/// # Examples
30///
31/// ```
32/// use feedparser_rs::util::base_url::is_safe_url;
33///
34/// // Safe URLs
35/// assert!(is_safe_url("http://example.com/"));
36/// assert!(is_safe_url("https://github.com/"));
37///
38/// // Unsafe URLs
39/// assert!(!is_safe_url("file:///etc/passwd"));
40/// assert!(!is_safe_url("http://localhost/"));
41/// assert!(!is_safe_url("http://192.168.1.1/"));
42/// assert!(!is_safe_url("http://169.254.169.254/"));
43/// ```
44#[must_use]
45pub fn is_safe_url(url: &str) -> bool {
46    ssrf::validate_url(url).is_ok()
47}
48
49/// Resolves a potentially relative URL against a base URL
50///
51/// If `href` is already absolute, returns it unchanged.
52/// If `base` is None or invalid, returns `href` unchanged.
53/// Otherwise, resolves `href` relative to `base`.
54///
55/// # Arguments
56///
57/// * `href` - The URL to resolve (may be relative or absolute)
58/// * `base` - The base URL to resolve against (may be None)
59///
60/// # Returns
61///
62/// The resolved URL as a string
63///
64/// # Examples
65///
66/// ```
67/// use feedparser_rs::util::base_url::resolve_url;
68///
69/// // Absolute URLs are returned unchanged
70/// assert_eq!(
71///     resolve_url("http://example.com/page", Some("http://other.com/")),
72///     "http://example.com/page"
73/// );
74///
75/// // Relative URLs are resolved against the base
76/// assert_eq!(
77///     resolve_url("page.html", Some("http://example.com/dir/")),
78///     "http://example.com/dir/page.html"
79/// );
80///
81/// // Without a base, relative URLs are returned unchanged
82/// assert_eq!(resolve_url("page.html", None), "page.html");
83/// ```
84#[must_use]
85pub fn resolve_url(href: &str, base: Option<&str>) -> String {
86    // If href is already absolute, return it
87    if href.starts_with("http://")
88        || href.starts_with("https://")
89        || href.starts_with("mailto:")
90        || href.starts_with("tel:")
91    {
92        return href.to_string();
93    }
94
95    // If no base URL, return href unchanged
96    let Some(base_str) = base else {
97        return href.to_string();
98    };
99
100    // Try to parse base URL
101    let Ok(base_url) = Url::parse(base_str) else {
102        return href.to_string();
103    };
104
105    // Resolve href against base
106    base_url
107        .join(href)
108        .map_or_else(|_| href.to_string(), |resolved| resolved.to_string())
109}
110
111/// Combines two base URLs, with child overriding parent
112///
113/// This handles nested `xml:base` attributes where a child element's
114/// base URL may be relative to its parent's base.
115///
116/// # Arguments
117///
118/// * `parent_base` - The parent element's base URL (may be None)
119/// * `child_base` - The child element's xml:base value (may be None)
120///
121/// # Returns
122///
123/// The effective base URL for the child element, or None if no base is set
124///
125/// # Examples
126///
127/// ```
128/// use feedparser_rs::util::base_url::combine_bases;
129///
130/// // Child absolute base overrides parent
131/// assert_eq!(
132///     combine_bases(Some("http://parent.com/"), Some("http://child.com/")),
133///     Some("http://child.com/".to_string())
134/// );
135///
136/// // Child relative base is resolved against parent
137/// assert_eq!(
138///     combine_bases(Some("http://example.com/feed/"), Some("items/")),
139///     Some("http://example.com/feed/items/".to_string())
140/// );
141///
142/// // No child base, parent is used
143/// assert_eq!(
144///     combine_bases(Some("http://example.com/"), None),
145///     Some("http://example.com/".to_string())
146/// );
147///
148/// // No bases at all
149/// assert_eq!(combine_bases(None, None), None);
150/// ```
151#[must_use]
152pub fn combine_bases(parent_base: Option<&str>, child_base: Option<&str>) -> Option<String> {
153    match (parent_base, child_base) {
154        (_, Some(child)) => {
155            // Child has a base - resolve it against parent if parent exists
156            Some(resolve_url(child, parent_base))
157        }
158        (Some(parent), None) => Some(parent.to_string()),
159        (None, None) => None,
160    }
161}
162
163/// Context for tracking base URLs during parsing
164///
165/// This struct maintains the current base URL context and provides
166/// methods for URL resolution within a parsing context.
167#[derive(Debug, Clone)]
168pub struct BaseUrlContext {
169    /// The current effective base URL
170    base: Option<String>,
171    /// Whether `resolve`/`resolve_safe` actually resolve relative URLs against
172    /// `base`, or pass `href` through unchanged (`ParseOptions::resolve_relative_uris`)
173    resolve: bool,
174}
175
176impl Default for BaseUrlContext {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182impl BaseUrlContext {
183    /// Creates a new context with no base URL
184    #[must_use]
185    pub const fn new() -> Self {
186        Self {
187            base: None,
188            resolve: true,
189        }
190    }
191
192    /// Creates a new context with an initial base URL
193    #[must_use]
194    pub fn with_base(base: impl Into<String>) -> Self {
195        Self {
196            base: Some(base.into()),
197            resolve: true,
198        }
199    }
200
201    /// Sets whether relative URL resolution is enabled (builder pattern)
202    ///
203    /// When `false`, [`Self::resolve`] never joins `href` against `base` — it
204    /// always returns `href` unchanged. [`Self::resolve_safe`] is different:
205    /// it still validates whatever `resolve` returns (dangerous schemes,
206    /// SSRF/private-IP checks) even when this is `false`, so it does **not**
207    /// simply return `href` unchanged — see its docs for why. Mirrors
208    /// `ParseOptions::resolve_relative_uris`.
209    #[must_use]
210    pub const fn with_resolve(mut self, resolve: bool) -> Self {
211        self.resolve = resolve;
212        self
213    }
214
215    /// Gets the current base URL
216    #[must_use]
217    pub fn base(&self) -> Option<&str> {
218        self.base.as_deref()
219    }
220
221    /// Updates the base URL with a new xml:base value
222    ///
223    /// The new base is resolved against the current base if it's relative.
224    pub fn update_base(&mut self, xml_base: &str) {
225        let new_base = resolve_url(xml_base, self.base.as_deref());
226        self.base = Some(new_base);
227    }
228
229    /// Resolves a URL against the current base
230    ///
231    /// Returns `href` unchanged when relative URL resolution is disabled
232    /// (see [`Self::with_resolve`]).
233    #[must_use]
234    pub fn resolve(&self, href: &str) -> String {
235        if !self.resolve {
236            return href.to_string();
237        }
238        resolve_url(href, self.base.as_deref())
239    }
240
241    /// Resolves a URL against the current base with SSRF protection
242    ///
243    /// This method performs URL resolution and validates the result to prevent
244    /// Server-Side Request Forgery (SSRF) attacks via malicious xml:base attributes.
245    ///
246    /// # Security
247    ///
248    /// If the resolved URL fails SSRF safety checks (localhost, private IPs,
249    /// dangerous schemes), the original `href` is returned unchanged instead
250    /// of the resolved URL.
251    ///
252    /// # Security
253    ///
254    /// These safety checks run **unconditionally**, even when relative URL
255    /// resolution is disabled via [`Self::with_resolve`]. `with_resolve(false)`
256    /// only disables joining `href` against `base`; it never skips scheme/SSRF
257    /// validation, so a feed-supplied `href` that is itself an absolute
258    /// dangerous or private-network URL is still blocked (#438).
259    ///
260    /// # Arguments
261    ///
262    /// * `href` - The URL to resolve (may be relative or absolute)
263    ///
264    /// # Returns
265    ///
266    /// The resolved URL if safe, otherwise the original `href`
267    ///
268    /// # Examples
269    ///
270    /// ```
271    /// use feedparser_rs::util::base_url::BaseUrlContext;
272    ///
273    /// // Safe URL resolution
274    /// let ctx = BaseUrlContext::with_base("http://example.com/");
275    /// assert_eq!(ctx.resolve_safe("page.html"), "http://example.com/page.html");
276    ///
277    /// // SSRF blocked - returns original href
278    /// let dangerous_ctx = BaseUrlContext::with_base("http://localhost/");
279    /// assert_eq!(dangerous_ctx.resolve_safe("admin"), "admin");
280    ///
281    /// // Safety checks still run when relative resolution is disabled
282    /// let no_resolve_ctx = BaseUrlContext::new().with_resolve(false);
283    /// assert_eq!(
284    ///     no_resolve_ctx.resolve_safe("http://169.254.169.254/latest/meta-data/"),
285    ///     ""
286    /// );
287    /// ```
288    #[must_use]
289    pub fn resolve_safe(&self, href: &str) -> String {
290        // `self.resolve()` already respects `self.resolve`: it returns `href`
291        // unchanged (no base joining) when relative resolution is disabled. The
292        // scheme/SSRF checks below always run on whatever it returns, so a
293        // feed-supplied absolute `href` is validated regardless of that setting.
294        let resolved = self.resolve(href);
295
296        // Use lowercase for case-insensitive scheme comparison (RFC 3986)
297        let resolved_lower = resolved.to_lowercase();
298
299        // Block dangerous schemes (file://, data://, javascript://, etc.)
300        // Case-insensitive to prevent bypass via FILE://, JAVASCRIPT:, etc.
301        if resolved_lower.starts_with("file://")
302            || resolved_lower.starts_with("data:")
303            || resolved_lower.starts_with("javascript:")
304            || resolved_lower.starts_with("ftp://")
305            || resolved_lower.starts_with("gopher://")
306        {
307            // Dangerous scheme - return original href
308            return href.to_string();
309        }
310
311        // Validate HTTP(S) URLs for SSRF
312        if resolved_lower.starts_with("http://") || resolved_lower.starts_with("https://") {
313            if is_safe_url(&resolved) {
314                resolved
315            } else {
316                // SSRF blocked - check if href itself is an unsafe absolute URL
317                // If href is an absolute URL pointing to dangerous target, return empty
318                // Otherwise return original relative href (safe since it requires base to resolve)
319                let href_is_unsafe_absolute = Url::parse(href).is_ok_and(|parsed_href| {
320                    let is_http_scheme = matches!(parsed_href.scheme(), "http" | "https");
321                    is_http_scheme && !is_safe_url(href)
322                });
323
324                if href_is_unsafe_absolute {
325                    String::new()
326                } else {
327                    href.to_string()
328                }
329            }
330        } else {
331            // Other schemes (mailto:, tel:) or relative URLs pass through
332            resolved
333        }
334    }
335
336    /// Creates a child context inheriting this context's base
337    #[must_use]
338    pub fn child(&self) -> Self {
339        Self {
340            base: self.base.clone(),
341            resolve: self.resolve,
342        }
343    }
344
345    /// Creates a child context with an additional xml:base
346    #[must_use]
347    pub fn child_with_base(&self, xml_base: &str) -> Self {
348        let new_base = combine_bases(self.base.as_deref(), Some(xml_base));
349        Self {
350            base: new_base,
351            resolve: self.resolve,
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_resolve_absolute_url() {
362        assert_eq!(
363            resolve_url("http://example.com/page", Some("http://other.com/")),
364            "http://example.com/page"
365        );
366        assert_eq!(
367            resolve_url("https://example.com/page", Some("http://other.com/")),
368            "https://example.com/page"
369        );
370    }
371
372    #[test]
373    fn test_resolve_relative_url() {
374        assert_eq!(
375            resolve_url("page.html", Some("http://example.com/dir/")),
376            "http://example.com/dir/page.html"
377        );
378        assert_eq!(
379            resolve_url("/absolute/path", Some("http://example.com/dir/")),
380            "http://example.com/absolute/path"
381        );
382        assert_eq!(
383            resolve_url("../sibling/page", Some("http://example.com/dir/sub/")),
384            "http://example.com/dir/sibling/page"
385        );
386    }
387
388    #[test]
389    fn test_resolve_without_base() {
390        assert_eq!(resolve_url("page.html", None), "page.html");
391        assert_eq!(
392            resolve_url("http://example.com", None),
393            "http://example.com"
394        );
395    }
396
397    #[test]
398    fn test_resolve_invalid_base() {
399        assert_eq!(
400            resolve_url("page.html", Some("not a valid url")),
401            "page.html"
402        );
403    }
404
405    #[test]
406    fn test_resolve_special_schemes() {
407        assert_eq!(
408            resolve_url("mailto:test@example.com", Some("http://example.com/")),
409            "mailto:test@example.com"
410        );
411        assert_eq!(
412            resolve_url("tel:+1234567890", Some("http://example.com/")),
413            "tel:+1234567890"
414        );
415    }
416
417    #[test]
418    fn test_combine_bases_child_absolute() {
419        assert_eq!(
420            combine_bases(Some("http://parent.com/"), Some("http://child.com/")),
421            Some("http://child.com/".to_string())
422        );
423    }
424
425    #[test]
426    fn test_combine_bases_child_relative() {
427        assert_eq!(
428            combine_bases(Some("http://example.com/feed/"), Some("items/")),
429            Some("http://example.com/feed/items/".to_string())
430        );
431    }
432
433    #[test]
434    fn test_combine_bases_no_child() {
435        assert_eq!(
436            combine_bases(Some("http://example.com/"), None),
437            Some("http://example.com/".to_string())
438        );
439    }
440
441    #[test]
442    fn test_combine_bases_no_parent() {
443        assert_eq!(
444            combine_bases(None, Some("http://example.com/")),
445            Some("http://example.com/".to_string())
446        );
447    }
448
449    #[test]
450    fn test_combine_bases_none() {
451        assert_eq!(combine_bases(None, None), None);
452    }
453
454    #[test]
455    fn test_context_new() {
456        let ctx = BaseUrlContext::new();
457        assert!(ctx.base().is_none());
458    }
459
460    #[test]
461    fn test_context_with_base() {
462        let ctx = BaseUrlContext::with_base("http://example.com/");
463        assert_eq!(ctx.base(), Some("http://example.com/"));
464    }
465
466    #[test]
467    fn test_context_update_base() {
468        let mut ctx = BaseUrlContext::with_base("http://example.com/feed/");
469        ctx.update_base("items/");
470        assert_eq!(ctx.base(), Some("http://example.com/feed/items/"));
471    }
472
473    #[test]
474    fn test_context_resolve() {
475        let ctx = BaseUrlContext::with_base("http://example.com/feed/");
476        assert_eq!(
477            ctx.resolve("item.html"),
478            "http://example.com/feed/item.html"
479        );
480        assert_eq!(ctx.resolve("http://other.com/"), "http://other.com/");
481    }
482
483    #[test]
484    fn test_context_child() {
485        let parent = BaseUrlContext::with_base("http://example.com/");
486        let child = parent.child();
487        assert_eq!(child.base(), Some("http://example.com/"));
488    }
489
490    #[test]
491    fn test_context_resolve_disabled() {
492        let ctx = BaseUrlContext::with_base("http://example.com/feed/").with_resolve(false);
493        assert_eq!(ctx.resolve("item.html"), "item.html");
494        assert_eq!(ctx.resolve_safe("item.html"), "item.html");
495    }
496
497    #[test]
498    fn test_context_resolve_disabled_propagates_to_children() {
499        let ctx = BaseUrlContext::with_base("http://example.com/").with_resolve(false);
500        assert_eq!(ctx.child().resolve("page.html"), "page.html");
501        assert_eq!(
502            ctx.child_with_base("sub/").resolve("page.html"),
503            "page.html"
504        );
505    }
506
507    #[test]
508    fn test_context_child_with_base() {
509        let parent = BaseUrlContext::with_base("http://example.com/feed/");
510        let child = parent.child_with_base("items/");
511        assert_eq!(child.base(), Some("http://example.com/feed/items/"));
512    }
513
514    #[test]
515    fn test_fragment_preservation() {
516        assert_eq!(
517            resolve_url("#section", Some("http://example.com/page.html")),
518            "http://example.com/page.html#section"
519        );
520    }
521
522    #[test]
523    fn test_query_string_preservation() {
524        assert_eq!(
525            resolve_url("?query=value", Some("http://example.com/page.html")),
526            "http://example.com/page.html?query=value"
527        );
528    }
529
530    #[test]
531    fn test_empty_href() {
532        // Empty href should resolve to base URL itself
533        assert_eq!(
534            resolve_url("", Some("http://example.com/page.html")),
535            "http://example.com/page.html"
536        );
537    }
538
539    // SSRF Protection Tests
540    #[test]
541    fn test_is_safe_url_file_scheme() {
542        assert!(!is_safe_url("file:///etc/passwd"));
543        assert!(!is_safe_url("file:///C:/Windows/System32/config/sam"));
544    }
545
546    #[test]
547    fn test_is_safe_url_localhost() {
548        assert!(!is_safe_url("http://localhost/"));
549        assert!(!is_safe_url("http://127.0.0.1/"));
550        assert!(!is_safe_url("http://[::1]/"));
551        assert!(!is_safe_url("https://localhost:8080/api"));
552    }
553
554    #[test]
555    fn test_is_safe_url_private_ip() {
556        // 192.168.x.x range
557        assert!(!is_safe_url("http://192.168.1.1/"));
558        assert!(!is_safe_url("http://192.168.0.1/"));
559        assert!(!is_safe_url("http://192.168.255.255/"));
560
561        // 10.x.x.x range
562        assert!(!is_safe_url("http://10.0.0.1/"));
563        assert!(!is_safe_url("http://10.255.255.255/"));
564
565        // 172.16-31.x.x range
566        assert!(!is_safe_url("http://172.16.0.1/"));
567        assert!(!is_safe_url("http://172.31.255.255/"));
568        assert!(!is_safe_url("http://172.20.10.5/"));
569
570        // 127.x.x.x range
571        assert!(!is_safe_url("http://127.0.0.2/"));
572        assert!(!is_safe_url("http://127.255.255.255/"));
573    }
574
575    #[test]
576    fn test_is_safe_url_cloud_metadata() {
577        assert!(!is_safe_url("http://169.254.169.254/"));
578        assert!(!is_safe_url("http://169.254.169.254/latest/meta-data/"));
579        assert!(!is_safe_url("http://metadata.google.internal/"));
580    }
581
582    #[test]
583    fn test_is_safe_url_valid_urls() {
584        assert!(is_safe_url("http://example.com/"));
585        assert!(is_safe_url("https://github.com/"));
586        assert!(is_safe_url("http://1.1.1.1/"));
587        assert!(is_safe_url("https://8.8.8.8/"));
588        assert!(is_safe_url("http://example.com:8080/path"));
589    }
590
591    #[test]
592    fn test_is_safe_url_other_schemes() {
593        assert!(!is_safe_url("ftp://example.com/"));
594        assert!(!is_safe_url("data:text/html,<script>alert('xss')</script>"));
595        assert!(!is_safe_url("javascript:alert('xss')"));
596        assert!(!is_safe_url("gopher://example.com/"));
597    }
598
599    #[test]
600    fn test_is_safe_url_ipv6() {
601        // Loopback
602        assert!(!is_safe_url("http://[::1]/"));
603        assert!(!is_safe_url("http://[0:0:0:0:0:0:0:1]/"));
604
605        // Private ULA (fc00::/7)
606        assert!(!is_safe_url("http://[fc00::1]/"));
607        assert!(!is_safe_url("http://[fd00::1]/"));
608
609        // Public IPv6 should be allowed
610        assert!(is_safe_url("http://[2001:4860:4860::8888]/"));
611    }
612
613    #[test]
614    fn test_is_safe_url_ipv4_mapped_ipv6_loopback_blocked() {
615        // Regression pin: before consolidating onto `util::ssrf`, this
616        // module's own is_private_ip()/is_loopback() checks did not unwrap
617        // IPv4-mapped IPv6 addresses, so `::ffff:127.0.0.1` (which reaches
618        // 127.0.0.1 on the wire) passed as "safe" through xml:base
619        // resolution.
620        assert!(!is_safe_url("http://[::ffff:127.0.0.1]/"));
621    }
622
623    #[test]
624    fn test_is_safe_url_invalid_urls() {
625        assert!(!is_safe_url("not a url"));
626        assert!(!is_safe_url(""));
627        assert!(!is_safe_url("://invalid"));
628    }
629}