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}