Skip to main content

sup_xml_core/
entity_resolver.rs

1#![forbid(unsafe_code)]  // see CONTRIBUTING.md § "Unsafe policy"
2
3//! Pluggable resolver for external XML resources (DTDs, parsed
4//! entities) referenced by SYSTEM / PUBLIC identifier.
5//!
6//! # Why this exists
7//!
8//! XML 1.0 lets DOCTYPE declarations and `<!ENTITY>` declarations
9//! reference external resources by URL.  Loading those resources
10//! is *the* primary XXE attack vector — a malicious document can
11//! cause a parser to fetch arbitrary URLs, leak local files, or
12//! trigger SSRF against internal services.
13//!
14//! SupXML's default behaviour is to refuse all external loading.
15//! Callers who genuinely need it (DocBook publishing, XHTML 1.0
16//! processing, JATS, etc.) opt in by setting
17//! `ParseOptions::external_resolver` to either:
18//!
19//! - The recommended [`FilesystemResolver`] — loads from a
20//!   configured allowlist of local directories, optionally
21//!   consulting an OASIS catalog first.
22//! - A [`NetworkResolver`](crate::entity_resolver::NetworkResolver)
23//!   (behind the `network-resolver` feature) — fetches over HTTPS
24//!   from a configured host allowlist with SSRF defenses.
25//! - A [`ChainedResolver`] composing the two — try local first,
26//!   fall back to network for anything not pre-cached.
27//! - A custom [`EntityResolver`] impl for bespoke setups
28//!   (in-memory bundles, S3, audit-logging, etc.).
29//!
30//! The presence of a resolver IS the opt-in.  Without one, every
31//! external reference is rejected.
32
33use std::collections::HashMap;
34use std::path::PathBuf;
35use std::sync::Arc;
36
37use crate::catalog::Catalog;
38
39/// Resolves external XML resources by their public/system
40/// identifier.  Implementors decide what URLs are loadable, where
41/// the bytes come from, and what security checks apply.
42///
43/// Implementations must be `Send + Sync` so the resolver can be
44/// shared across threads or used from async contexts.
45pub trait EntityResolver: Send + Sync + std::fmt::Debug {
46    /// Resolve an external entity reference.
47    ///
48    /// - `public_id`: the FPI (formal public identifier) from a
49    ///   PUBLIC declaration, if present.  Catalog-based resolvers
50    ///   try this first per OASIS § 7.1.1.
51    /// - `system_id`: an *already-absolute* SYSTEM URL.  The parser
52    ///   performs base-URI resolution (XML 1.0 § 4.2.2 + errata
53    ///   E18) before calling — relative literals in the DTD are
54    ///   joined against the document URL for general-entity
55    ///   declarations and against the containing entity's URL for
56    ///   parameter-entity declarations.  Resolvers that don't
57    ///   consult a catalog use this to locate the bytes; no URI
58    ///   joining is required from the implementation.
59    /// - `base_uri`: the base URI the parser used when pre-joining
60    ///   the SYSTEM identifier.  Informational — most resolvers
61    ///   can ignore it.  Catalog-aware resolvers may consult it
62    ///   when the catalog lookup falls back to filesystem (e.g.
63    ///   to scope a security check), and resolvers that want to
64    ///   log or report the *original* document context can use it.
65    ///   Correctness does **not** require consuming this parameter.
66    ///
67    /// Returns the entity's bytes on success.  Use
68    /// [`ResolveError::Refused`] when the resolver chose not to
69    /// load (security policy denied) versus
70    /// [`ResolveError::Io`] when loading failed for an external
71    /// reason.
72    fn resolve(
73        &self,
74        public_id: Option<&str>,
75        system_id: &str,
76        base_uri: Option<&str>,
77    ) -> Result<Vec<u8>, ResolveError>;
78}
79
80/// Why a resolver couldn't deliver the requested bytes.
81#[derive(Debug)]
82pub enum ResolveError {
83    /// The resolver refused the request — security policy denied
84    /// it (URL not in allowlist, scheme not allowed, host blocked,
85    /// etc.).  Distinguished from `Io` so [`ChainedResolver`] can
86    /// fall through to the next resolver in the chain.
87    Refused(String),
88    /// Loading was attempted but failed — file not found, network
89    /// error, TLS failure, response too large, etc.
90    Io(String),
91    /// Resolver-specific error not covered above.
92    Other(String),
93}
94
95impl std::fmt::Display for ResolveError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            ResolveError::Refused(s) => write!(f, "resolver refused: {s}"),
99            ResolveError::Io(s)      => write!(f, "resolver I/O error: {s}"),
100            ResolveError::Other(s)   => write!(f, "resolver error: {s}"),
101        }
102    }
103}
104
105impl std::error::Error for ResolveError {}
106
107// ── FilesystemResolver ─────────────────────────────────────────
108
109/// Recommended default resolver: loads from filesystem only,
110/// refuses anything outside the configured allowed-root
111/// directories, optionally consults an OASIS catalog first.
112///
113/// The allowed-roots list is the *security boundary*.  Empty list
114/// = nothing will resolve.  Pass specific directories like
115/// `/usr/share/xml`; never pass `/`.
116#[derive(Debug, Clone)]
117pub struct FilesystemResolver {
118    catalog: Option<Catalog>,
119    allowed_roots: Vec<PathBuf>,
120}
121
122impl FilesystemResolver {
123    /// Construct a resolver allowed to read from the given
124    /// directories.  Files outside these directories will be
125    /// refused even if the catalog or the system_id points at
126    /// them.
127    pub fn new(allowed_roots: Vec<PathBuf>) -> Self {
128        Self { catalog: None, allowed_roots }
129    }
130
131    /// Builder: consult this catalog before falling back to the
132    /// raw filesystem.  PUBLIC IDs in the catalog map to local
133    /// `file://` URIs.
134    pub fn with_catalog(mut self, catalog: Catalog) -> Self {
135        self.catalog = Some(catalog);
136        self
137    }
138
139}
140
141impl FilesystemResolver {
142    /// Catalog lookup + scheme normalisation + canonicalise +
143    /// allowlist check.  Returns the **canonical** path on
144    /// success — symlinks resolved, ready to hand to
145    /// [`Self::read_validated`].  Crate-internal so tests can
146    /// exercise the canonicalize-then-read boundary that the
147    /// TOCTOU mitigation hinges on.
148    pub(crate) fn validate_path(
149        &self,
150        public_id: Option<&str>,
151        system_id: &str,
152    ) -> Result<PathBuf, ResolveError> {
153        // Catalog lookup first (PUBLIC takes precedence, then
154        // SYSTEM per OASIS § 7.1.1).  If the catalog returns a
155        // mapping, use that path; otherwise fall back to system_id.
156        // Catalog returns a `Cow` — direct PUBLIC/SYSTEM matches
157        // borrow from the catalog (no alloc); rewrite entries own
158        // the synthesised URI.  We always need an owned `String`
159        // here (it's handed to `Path::new` and eventually to
160        // `canonicalize`), so `.into_owned()` either reuses the
161        // owned variant or allocates once for the borrowed case.
162        let target = self.catalog.as_ref()
163            .and_then(|c| c.resolve(public_id, Some(system_id)))
164            .map(|c| c.into_owned())
165            .unwrap_or_else(|| system_id.to_string());
166
167        // Strip the file:// scheme if present.  Refuse any other
168        // scheme — this resolver does filesystem only.
169        let path_str = if let Some(rest) = target.strip_prefix("file://") {
170            rest
171        } else if target.contains("://") {
172            return Err(ResolveError::Refused(format!(
173                "FilesystemResolver only handles file:// URIs, got: {target}"
174            )));
175        } else {
176            &target
177        };
178        let path = PathBuf::from(path_str);
179
180        // Canonicalise once; reject if not within allowed roots.
181        // The canonical path is what we'll pass to read_validated
182        // — it has all symlinks already resolved, so a follow-up
183        // symlink swap at the *original* path can't redirect the
184        // read.
185        let canonical = path.canonicalize().map_err(|_| {
186            ResolveError::Refused(format!(
187                "path {} is outside the configured allowed roots",
188                path.display()
189            ))
190        })?;
191        if !self.allowed_roots.iter().any(|root| {
192            root.canonicalize()
193                .map(|cr| canonical.starts_with(&cr))
194                .unwrap_or(false)
195        }) {
196            return Err(ResolveError::Refused(format!(
197                "path {} is outside the configured allowed roots",
198                path.display()
199            )));
200        }
201        Ok(canonical)
202    }
203
204    /// Read bytes from a path previously validated by
205    /// [`Self::validate_path`].  On Unix the open refuses to
206    /// follow a symlink at the final path component — closing
207    /// the canonicalize→read TOCTOU window where an attacker
208    /// with write access in the allowed root swaps the file to
209    /// a symlink between the validate and read steps.
210    pub(crate) fn read_validated(
211        &self,
212        canonical: &std::path::Path,
213    ) -> Result<Vec<u8>, ResolveError> {
214        use std::io::Read;
215        let file = open_no_follow(canonical).map_err(|e| ResolveError::Io(format!(
216            "reading {}: {e}", canonical.display()
217        )))?;
218        let mut buf = Vec::new();
219        let mut reader = file;
220        reader.read_to_end(&mut buf).map_err(|e| ResolveError::Io(format!(
221            "reading {}: {e}", canonical.display()
222        )))?;
223        Ok(buf)
224    }
225}
226
227/// Open a file for reading, refusing to follow a symlink at the
228/// final path component on Unix.  On non-Unix platforms this is
229/// a plain open of the canonical path; the canonicalize-step in
230/// [`FilesystemResolver::validate_path`] still resolves
231/// pre-existing symlinks, leaving only a small residual window
232/// for an attacker who can swap the file at the canonical
233/// location between validate and read.
234fn open_no_follow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
235    // POSIX-stable `O_NOFOLLOW` value, inlined to avoid pulling
236    // in `libc` for a single constant.  Linux/Android use the
237    // glibc value; the BSD-derived family (Apple + the actual
238    // BSDs) share the historical 0x100.
239    #[cfg(any(target_os = "linux", target_os = "android"))]
240    const O_NOFOLLOW: i32 = 0o400000;
241    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd",
242              target_os = "netbsd", target_os = "openbsd", target_os = "dragonfly"))]
243    const O_NOFOLLOW: i32 = 0x0100;
244
245    #[cfg(unix)]
246    {
247        use std::os::unix::fs::OpenOptionsExt;
248        std::fs::OpenOptions::new()
249            .read(true)
250            .custom_flags(O_NOFOLLOW)
251            .open(path)
252    }
253    #[cfg(not(unix))]
254    {
255        std::fs::File::open(path)
256    }
257}
258
259impl EntityResolver for FilesystemResolver {
260    fn resolve(
261        &self,
262        public_id: Option<&str>,
263        system_id: &str,
264        _base_uri: Option<&str>,
265    ) -> Result<Vec<u8>, ResolveError> {
266        let canonical = self.validate_path(public_id, system_id)?;
267        self.read_validated(&canonical)
268    }
269}
270
271// ── ChainedResolver ────────────────────────────────────────────
272
273/// Composes multiple resolvers, trying each in order.  The first
274/// resolver to return either `Ok` or a non-`Refused` error wins;
275/// `Refused` falls through to the next resolver.
276///
277/// Typical use: filesystem first (cheap, deterministic), network
278/// second (slower, requires network access).
279#[derive(Debug)]
280pub struct ChainedResolver {
281    resolvers: Vec<Arc<dyn EntityResolver>>,
282}
283
284impl ChainedResolver {
285    pub fn new(resolvers: Vec<Arc<dyn EntityResolver>>) -> Self {
286        Self { resolvers }
287    }
288}
289
290impl EntityResolver for ChainedResolver {
291    fn resolve(
292        &self,
293        public_id: Option<&str>,
294        system_id: &str,
295        base_uri: Option<&str>,
296    ) -> Result<Vec<u8>, ResolveError> {
297        let mut last_refused: Option<ResolveError> = None;
298        for r in &self.resolvers {
299            match r.resolve(public_id, system_id, base_uri) {
300                Ok(bytes) => return Ok(bytes),
301                Err(e @ ResolveError::Refused(_)) => {
302                    last_refused = Some(e);
303                    continue;
304                }
305                Err(other) => return Err(other),
306            }
307        }
308        // All resolvers refused.  Surface the last one for
309        // diagnostic purposes.
310        Err(last_refused.unwrap_or_else(|| ResolveError::Refused(
311            "no resolvers in chain".to_string()
312        )))
313    }
314}
315
316// ── InMemoryResolver (mainly for tests, occasionally for
317//    embedded resources) ────────────────────────────────────────
318
319/// Resolver backed by an in-memory map from system_id to bytes.
320/// Refuses anything not in the map.  Useful in tests where you
321/// want deterministic resolution without filesystem dependencies.
322#[derive(Debug, Default, Clone)]
323pub struct InMemoryResolver {
324    by_system_id: HashMap<String, Vec<u8>>,
325    by_public_id: HashMap<String, Vec<u8>>,
326}
327
328impl InMemoryResolver {
329    pub fn new() -> Self { Self::default() }
330
331    pub fn with_system(mut self, system_id: &str, bytes: Vec<u8>) -> Self {
332        self.by_system_id.insert(system_id.to_string(), bytes);
333        self
334    }
335
336    pub fn with_public(mut self, public_id: &str, bytes: Vec<u8>) -> Self {
337        self.by_public_id.insert(public_id.to_string(), bytes);
338        self
339    }
340}
341
342impl EntityResolver for InMemoryResolver {
343    fn resolve(
344        &self,
345        public_id: Option<&str>,
346        system_id: &str,
347        _base_uri: Option<&str>,
348    ) -> Result<Vec<u8>, ResolveError> {
349        if let Some(p) = public_id {
350            if let Some(b) = self.by_public_id.get(p) {
351                return Ok(b.clone());
352            }
353        }
354        if let Some(b) = self.by_system_id.get(system_id) {
355            return Ok(b.clone());
356        }
357        Err(ResolveError::Refused(format!(
358            "InMemoryResolver has no entry for system_id={system_id:?} public_id={public_id:?}"
359        )))
360    }
361}
362
363// ── NetworkResolver (feature-gated) ────────────────────────────
364
365#[cfg(feature = "network-resolver")]
366mod network {
367    use super::*;
368    use std::collections::HashSet;
369    use std::net::IpAddr;
370    use std::sync::Mutex;
371    use std::time::Duration;
372
373    /// Pluggable DNS resolver — used by [`NetworkResolver`] for
374    /// the up-front IP check and for pinning the IP into the
375    /// ureq agent so the connection can't follow a rebound DNS
376    /// answer.  Crate-internal: the only intended consumers are
377    /// the default [`StdDnsResolver`] and crate tests that
378    /// inject mock impls.  Not part of the public API — promote
379    /// to `pub` if/when an external use case appears.
380    pub(crate) trait DnsResolver: Send + Sync + std::fmt::Debug {
381        fn lookup(&self, host: &str, port: u16) -> Vec<std::net::SocketAddr>;
382    }
383
384    /// Default DNS resolver — `getaddrinfo` via std.
385    #[derive(Debug, Default)]
386    pub(crate) struct StdDnsResolver;
387
388    impl DnsResolver for StdDnsResolver {
389        fn lookup(&self, host: &str, port: u16) -> Vec<std::net::SocketAddr> {
390            use std::net::ToSocketAddrs;
391            (host, port).to_socket_addrs()
392                .map(|iter| iter.collect())
393                .unwrap_or_default()
394        }
395    }
396
397    /// HTTPS-fetching resolver.  Hardened by default with multiple
398    /// SSRF and amplification defenses; the constructor *requires*
399    /// a host allowlist so there's no convenient "any host" mode.
400    ///
401    /// **Defaults:**
402    /// - HTTPS only (use [`with_plaintext_http`] to allow `http://`)
403    /// - Refuses URLs whose resolved IP is RFC 1918 / loopback /
404    ///   link-local (use [`with_private_ips_allowed`] to disable)
405    /// - 10-second per-request timeout
406    /// - 1 MB max response size
407    /// - 64 MB in-memory LRU cache (per resolver instance)
408    pub struct NetworkResolver {
409        allowed_hosts:        HashSet<String>,
410        block_private_ips:    bool,
411        allow_plaintext_http: bool,
412        max_response_bytes:   usize,
413        timeout:              Duration,
414        cache:                Mutex<lru::Cache>,
415        /// DNS resolver used both for the up-front IP check and
416        /// for pinning the IP that ureq connects to.  Sharing a
417        /// single instance prevents a DNS-rebinding TOCTOU problem
418        /// because we resolve once, verify, then hand the verified
419        /// socket address to ureq so it doesn't re-query.
420        dns:                  std::sync::Arc<dyn DnsResolver>,
421    }
422
423    impl std::fmt::Debug for NetworkResolver {
424        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425            f.debug_struct("NetworkResolver")
426                .field("allowed_hosts",        &self.allowed_hosts)
427                .field("block_private_ips",    &self.block_private_ips)
428                .field("allow_plaintext_http", &self.allow_plaintext_http)
429                .field("max_response_bytes",   &self.max_response_bytes)
430                .field("timeout",              &self.timeout)
431                .finish_non_exhaustive()
432        }
433    }
434
435    impl NetworkResolver {
436        /// Construct with the required host allowlist.  Hosts are
437        /// matched exactly (no wildcards) against the URL's host
438        /// component.  All other settings get safe defaults; use
439        /// the `with_*` builders to relax them.
440        pub fn new<I: IntoIterator<Item = String>>(allowed_hosts: I) -> Self {
441            Self {
442                allowed_hosts:        allowed_hosts.into_iter().collect(),
443                block_private_ips:    true,
444                allow_plaintext_http: false,
445                max_response_bytes:   1 * 1024 * 1024,    // 1 MB
446                timeout:              Duration::from_secs(10),
447                cache:                Mutex::new(lru::Cache::new(64 * 1024 * 1024)),
448                dns:                  std::sync::Arc::new(StdDnsResolver),
449            }
450        }
451
452        /// Override the DNS resolver.  Crate-internal test seam —
453        /// used by the network_tests module to inject a mock DNS
454        /// that maps a synthetic hostname to a local listener.
455        /// Not part of the public API; if a real external use
456        /// case appears (custom resolvers, hosts overrides),
457        /// promote to `pub` and stabilise the [`DnsResolver`]
458        /// trait at the same time.
459        #[cfg(test)]
460        pub(crate) fn with_dns_resolver(mut self, dns: std::sync::Arc<dyn DnsResolver>) -> Self {
461            self.dns = dns;
462            self
463        }
464
465        /// Allow `http://` URLs (default: HTTPS-only).  Almost
466        /// always wrong; use only for testing or air-gapped
467        /// networks where TLS isn't available.
468        pub fn with_plaintext_http(mut self) -> Self {
469            self.allow_plaintext_http = true;
470            self
471        }
472
473        /// Allow URLs whose resolved IP is RFC 1918 private,
474        /// loopback, or link-local (default: refused for SSRF
475        /// defense).  Disable only if your `allowed_hosts` list
476        /// already constrains to trusted internal hosts.
477        pub fn with_private_ips_allowed(mut self) -> Self {
478            self.block_private_ips = false;
479            self
480        }
481
482        pub fn with_max_response_bytes(mut self, n: usize) -> Self {
483            self.max_response_bytes = n;
484            self
485        }
486
487        pub fn with_timeout(mut self, d: Duration) -> Self {
488            self.timeout = d;
489            self
490        }
491
492        pub fn with_cache_size(mut self, max_total_bytes: usize) -> Self {
493            self.cache = Mutex::new(lru::Cache::new(max_total_bytes));
494            self
495        }
496
497        /// Validate a URL against our security policy.  Returns
498        /// `(host, port, verified_addrs)` — the verified DNS
499        /// resolution result is threaded onward to ureq so it
500        /// connects to the IP we checked and never re-queries
501        /// DNS.  Refuses otherwise.  We extract scheme/host with
502        /// cheap manual parsing rather than pulling in the `url`
503        /// crate.
504        fn check_url(&self, url: &str)
505            -> Result<(String, u16, Vec<std::net::SocketAddr>), ResolveError>
506        {
507            // Split on `://`.
508            let (scheme, rest) = url.split_once("://").ok_or_else(|| {
509                ResolveError::Refused(format!("URL {url:?} missing scheme://"))
510            })?;
511            let default_port: u16 = match scheme {
512                "https" => 443,
513                "http" if self.allow_plaintext_http => 80,
514                other => return Err(ResolveError::Refused(format!(
515                    "scheme {other:?} not allowed (use with_plaintext_http() to permit http://)"
516                ))),
517            };
518            // Host is bytes up to the first `/`, `?`, `#`, or end.
519            // Strip optional port.  We don't support userinfo
520            // (`user:pass@`) — if you need it, use a custom resolver.
521            let auth = rest.split(|c: char| matches!(c, '/' | '?' | '#'))
522                .next().unwrap_or("");
523            if auth.is_empty() {
524                return Err(ResolveError::Refused(
525                    format!("URL {url:?} has no host component")
526                ));
527            }
528            if auth.contains('@') {
529                return Err(ResolveError::Refused(format!(
530                    "URLs with userinfo (user@host) are not supported by NetworkResolver"
531                )));
532            }
533            let (host, port) = match auth.rsplit_once(':') {
534                Some((h, p)) => {
535                    // Skip if `:` is inside an IPv6 literal `[…]`.
536                    if h.starts_with('[') {
537                        (auth, default_port)
538                    } else {
539                        let port = p.parse::<u16>().map_err(|e| ResolveError::Refused(
540                            format!("invalid port in URL {url:?}: {e}")
541                        ))?;
542                        (h, port)
543                    }
544                }
545                None => (auth, default_port),
546            };
547            if !self.allowed_hosts.contains(host) {
548                return Err(ResolveError::Refused(format!(
549                    "host {host:?} is not in the allowed-hosts list"
550                )));
551            }
552            // Resolve DNS ONCE here and (post-fix) pin the result
553            // into the ureq agent.  Doing the lookup a second time
554            // inside ureq would re-open a DNS-rebinding TOCTOU
555            // window where an attacker controlling DNS for an
556            // allowlisted host returns a public IP first (passes
557            // the private-IP check) then 169.254.169.254 (IMDS).
558            let addrs = self.dns.lookup(host, port);
559            if self.block_private_ips {
560                for sa in &addrs {
561                    let ip = sa.ip();
562                    if is_private_or_loopback(&ip) {
563                        return Err(ResolveError::Refused(format!(
564                            "host {host:?} resolves to private/loopback IP {ip} \
565                             (use with_private_ips_allowed() to permit)"
566                        )));
567                    }
568                }
569            }
570            Ok((host.to_string(), port, addrs))
571        }
572    }
573
574    impl EntityResolver for NetworkResolver {
575        fn resolve(
576            &self,
577            _public_id: Option<&str>,
578            system_id: &str,
579            _base_uri: Option<&str>,
580        ) -> Result<Vec<u8>, ResolveError> {
581            // Cache hit?
582            if let Some(bytes) = self.cache.lock().unwrap().get(system_id) {
583                return Ok(bytes);
584            }
585            // Validate URL + host + IP and capture the verified
586            // socket addresses so we can pin them into ureq.
587            let (_host, _port, verified_addrs) = self.check_url(system_id)?;
588
589            // Issue the request.  Pinning DNS: ureq's agent gets
590            // a custom resolver that returns the addresses we
591            // already verified, so it never performs its own
592            // lookup and an attacker controlling DNS can't
593            // rebind between our private-IP check and the actual
594            // connect.  TLS SNI / Host header stay correct
595            // because the URL still carries the hostname.
596            use std::io::Read;
597            let pinned = verified_addrs.clone();
598            let agent = ureq::AgentBuilder::new()
599                .timeout(self.timeout)
600                .resolver(move |_addr: &str| -> std::io::Result<Vec<std::net::SocketAddr>> {
601                    Ok(pinned.clone())
602                })
603                .build();
604            let resp = agent.get(system_id).call()
605                .map_err(|e| ResolveError::Io(format!("HTTP request failed: {e}")))?;
606            let reader = resp.into_reader();
607            let mut limited = reader.take(self.max_response_bytes as u64 + 1);
608            let mut bytes = Vec::with_capacity(8 * 1024);
609            limited.read_to_end(&mut bytes)
610                .map_err(|e| ResolveError::Io(format!("reading response body: {e}")))?;
611            if bytes.len() > self.max_response_bytes {
612                return Err(ResolveError::Refused(format!(
613                    "response body exceeds max_response_bytes ({})",
614                    self.max_response_bytes
615                )));
616            }
617            // Cache and return.
618            self.cache.lock().unwrap().insert(system_id.to_string(), bytes.clone());
619            Ok(bytes)
620        }
621    }
622
623    fn is_private_or_loopback(ip: &IpAddr) -> bool {
624        match ip {
625            IpAddr::V4(v4) => {
626                v4.is_private() || v4.is_loopback() || v4.is_link_local()
627                    || v4.is_unspecified() || v4.is_broadcast()
628            }
629            IpAddr::V6(v6) => {
630                v6.is_loopback() || v6.is_unspecified()
631                    // Unique-local fc00::/7
632                    || (v6.segments()[0] & 0xfe00) == 0xfc00
633                    // Link-local fe80::/10
634                    || (v6.segments()[0] & 0xffc0) == 0xfe80
635            }
636        }
637    }
638
639    /// Tiny LRU byte cache — entries evict in insertion order
640    /// once total stored bytes exceed the cap.  Not optimised;
641    /// good enough for the few-DTDs-per-process case
642    /// `NetworkResolver` is meant for.  ~80 lines vs pulling in
643    /// the `lru` crate.
644    mod lru {
645        use std::collections::VecDeque;
646
647        pub(super) struct Cache {
648            cap: usize,
649            cur: usize,
650            entries: VecDeque<(String, Vec<u8>)>,
651        }
652
653        impl Cache {
654            pub(super) fn new(cap: usize) -> Self {
655                Self { cap, cur: 0, entries: VecDeque::new() }
656            }
657
658            pub(super) fn get(&mut self, key: &str) -> Option<Vec<u8>> {
659                let pos = self.entries.iter().position(|(k, _)| k == key)?;
660                // Move to back (most-recently-used).
661                let entry = self.entries.remove(pos)?;
662                let bytes = entry.1.clone();
663                self.entries.push_back(entry);
664                Some(bytes)
665            }
666
667            pub(super) fn insert(&mut self, key: String, value: Vec<u8>) {
668                self.cur += value.len();
669                self.entries.push_back((key, value));
670                while self.cur > self.cap {
671                    if let Some((_, v)) = self.entries.pop_front() {
672                        self.cur -= v.len();
673                    } else { break; }
674                }
675            }
676        }
677    }
678
679}
680
681#[cfg(feature = "network-resolver")]
682pub use network::NetworkResolver;
683
684// Crate-internal seam reachable by the in-file `network_tests`
685// module — kept out of the public API.
686#[cfg(all(test, feature = "network-resolver"))]
687use network::DnsResolver;
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn fs_resolver_refuses_path_outside_roots() {
695        let r = FilesystemResolver::new(vec![std::env::temp_dir()]);
696        let err = r.resolve(None, "file:///etc/passwd", None).unwrap_err();
697        assert!(matches!(err, ResolveError::Refused(_)),
698            "expected Refused, got {err:?}");
699    }
700
701    #[test]
702    fn fs_resolver_refuses_non_file_scheme() {
703        let r = FilesystemResolver::new(vec![std::env::temp_dir()]);
704        let err = r.resolve(None, "https://example.com/foo.dtd", None).unwrap_err();
705        match err {
706            ResolveError::Refused(msg) => assert!(msg.contains("file://")),
707            other => panic!("expected Refused about file://, got {other:?}"),
708        }
709    }
710
711    #[test]
712    fn fs_resolver_loads_from_allowed_root() {
713        let dir = std::env::temp_dir();
714        let path = dir.join("sup-xml-fs-resolver-test.dtd");
715        std::fs::write(&path, b"<!-- a test DTD -->").unwrap();
716
717        let r = FilesystemResolver::new(vec![dir.clone()]);
718        let bytes = r.resolve(None, &format!("file://{}", path.display()), None)
719            .expect("should load successfully");
720        assert_eq!(bytes, b"<!-- a test DTD -->");
721
722        let _ = std::fs::remove_file(&path);
723    }
724
725    /// Security regression for the canonicalize→read TOCTOU:
726    /// after [`FilesystemResolver::validate_path`] returns a
727    /// canonical path inside the allowed roots, the
728    /// corresponding [`FilesystemResolver::read_validated`] call
729    /// must NOT follow a symlink that an attacker has just
730    /// dropped at that location.
731    ///
732    /// The split between `validate_path` and `read_validated`
733    /// gives us a deterministic stand-in for the race: we
734    /// perform the swap from the test thread between the two
735    /// calls, simulating the worst-case attacker timing.
736    #[test]
737    #[cfg(unix)]
738    fn fs_resolver_refuses_symlink_swap_between_validate_and_read() {
739        use std::os::unix::fs::symlink;
740        let allowed = std::env::temp_dir()
741            .join(format!("sup-xml-fs-toctou-allowed-{}", std::process::id()));
742        std::fs::create_dir_all(&allowed).unwrap();
743        let inside = allowed.join("inner.dtd");
744        std::fs::write(&inside, b"INSIDE").unwrap();
745        let outside = std::env::temp_dir()
746            .join(format!("sup-xml-fs-toctou-secret-{}.dtd", std::process::id()));
747        std::fs::write(&outside, b"SECRET").unwrap();
748
749        let r = FilesystemResolver::new(vec![allowed.clone()]);
750        let system_id = format!("file://{}", inside.display());
751        // Time-of-check: regular file → canonical path inside allowed
752        // roots → validation passes.
753        let canonical = r.validate_path(None, &system_id)
754            .expect("validate should pass for legitimate file inside allowed root");
755
756        // The race: attacker replaces `inside` with a symlink to
757        // `outside` — what a TOCTOU attacker would do between the
758        // resolver's canonicalize and its read.
759        std::fs::remove_file(&inside).unwrap();
760        symlink(&outside, &inside).unwrap();
761
762        // Time-of-use: the resolver must refuse to follow the
763        // newly-planted symlink.  Either an Io error from
764        // O_NOFOLLOW, or somehow the safe content — never the
765        // secret bytes.
766        let result = r.read_validated(&canonical);
767        let _ = std::fs::remove_file(&inside);
768        let _ = std::fs::remove_dir_all(&allowed);
769        let _ = std::fs::remove_file(&outside);
770        match result {
771            Ok(bytes) => assert_ne!(
772                bytes, b"SECRET",
773                "TOCTOU: read followed the swapped symlink and returned out-of-root content"
774            ),
775            Err(_) => {}  // refusal is the safe outcome
776        }
777    }
778
779    #[test]
780    fn fs_resolver_consults_catalog_first() {
781        let dir = std::env::temp_dir();
782        let real_path = dir.join("sup-xml-fs-resolver-cat-test.dtd");
783        std::fs::write(&real_path, b"loaded via catalog").unwrap();
784
785        // Catalog maps a public ID to the local file.
786        let cat_xml = format!(r#"<?xml version="1.0"?>
787            <catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
788                <public publicId="-//Test//DTD//EN" uri="file://{}"/>
789            </catalog>"#, real_path.display());
790        let cat = Catalog::parse(cat_xml.as_bytes()).unwrap();
791
792        let r = FilesystemResolver::new(vec![dir.clone()]).with_catalog(cat);
793        let bytes = r.resolve(
794            Some("-//Test//DTD//EN"),
795            "http://example.com/never-loaded",  // catalog hijacks; we never read this
796            None,
797        ).unwrap();
798        assert_eq!(bytes, b"loaded via catalog");
799
800        let _ = std::fs::remove_file(&real_path);
801    }
802
803    #[test]
804    fn in_memory_resolver_round_trip() {
805        let r = InMemoryResolver::new()
806            .with_public("-//Test//DTD//EN", b"public-bytes".to_vec())
807            .with_system("http://example.com/foo.dtd", b"system-bytes".to_vec());
808
809        // PUBLIC match.
810        let b = r.resolve(Some("-//Test//DTD//EN"), "http://nope", None).unwrap();
811        assert_eq!(b, b"public-bytes");
812        // SYSTEM-only match.
813        let b = r.resolve(None, "http://example.com/foo.dtd", None).unwrap();
814        assert_eq!(b, b"system-bytes");
815        // No match.
816        let err = r.resolve(None, "http://other", None).unwrap_err();
817        assert!(matches!(err, ResolveError::Refused(_)));
818    }
819
820    #[test]
821    fn chained_falls_through_refused() {
822        // First resolver always refuses; second one has the entry.
823        let in_mem = InMemoryResolver::new()
824            .with_system("foo", b"from-second".to_vec());
825        let chain = ChainedResolver::new(vec![
826            Arc::new(InMemoryResolver::new()),  // empty → refuses everything
827            Arc::new(in_mem),
828        ]);
829        let bytes = chain.resolve(None, "foo", None).unwrap();
830        assert_eq!(bytes, b"from-second");
831    }
832
833    #[test]
834    fn chained_propagates_io_errors() {
835        // A non-Refused error stops the chain — the caller sees
836        // the I/O failure rather than us silently trying the next
837        // resolver.  Use a custom resolver that returns Io.
838        #[derive(Debug)]
839        struct Failer;
840        impl EntityResolver for Failer {
841            fn resolve(&self, _: Option<&str>, _: &str, _: Option<&str>)
842                -> Result<Vec<u8>, ResolveError> {
843                Err(ResolveError::Io("simulated network failure".into()))
844            }
845        }
846        let chain = ChainedResolver::new(vec![
847            Arc::new(Failer),
848            Arc::new(InMemoryResolver::new().with_system("foo", b"x".to_vec())),
849        ]);
850        let err = chain.resolve(None, "foo", None).unwrap_err();
851        assert!(matches!(err, ResolveError::Io(_)));
852    }
853
854    #[test]
855    fn empty_chain_returns_refused() {
856        let chain = ChainedResolver::new(vec![]);
857        let err = chain.resolve(None, "foo", None).unwrap_err();
858        assert!(matches!(err, ResolveError::Refused(_)));
859    }
860
861    // ── NetworkResolver tests (security-policy paths only — no
862    //    real network calls) ──────────────────────────────────────
863    #[cfg(feature = "network-resolver")]
864    mod network_tests {
865        use super::*;
866
867        #[test]
868        fn refuses_non_https_by_default() {
869            let r = NetworkResolver::new(["example.com".to_string()]);
870            let err = r.resolve(None, "http://example.com/foo.dtd", None).unwrap_err();
871            match err {
872                ResolveError::Refused(msg) => assert!(msg.contains("scheme")),
873                other => panic!("expected Refused about scheme, got {other:?}"),
874            }
875        }
876
877        #[test]
878        fn refuses_unknown_host() {
879            let r = NetworkResolver::new(["allowed.example.com".to_string()]);
880            let err = r.resolve(None, "https://other.example.com/foo.dtd", None)
881                .unwrap_err();
882            match err {
883                ResolveError::Refused(msg) => assert!(msg.contains("not in the allowed-hosts")),
884                other => panic!("expected Refused about host allowlist, got {other:?}"),
885            }
886        }
887
888        #[test]
889        fn refuses_userinfo_in_url() {
890            let r = NetworkResolver::new(["example.com".to_string()]);
891            let err = r.resolve(None, "https://user:pass@example.com/foo.dtd", None)
892                .unwrap_err();
893            match err {
894                ResolveError::Refused(msg) => assert!(msg.contains("userinfo")),
895                other => panic!("expected Refused about userinfo, got {other:?}"),
896            }
897        }
898
899        #[test]
900        fn refuses_malformed_url() {
901            let r = NetworkResolver::new(["example.com".to_string()]);
902            let err = r.resolve(None, "not-a-url-at-all", None).unwrap_err();
903            assert!(matches!(err, ResolveError::Refused(_)));
904        }
905
906        /// Security regression for the DNS-rebinding TOCTOU window.
907        /// The resolver must hand ureq the IP it already verified
908        /// — otherwise ureq performs its own DNS lookup and an
909        /// attacker controlling DNS for an allowlisted host can
910        /// return a public IP for the private-IP check then a
911        /// private IP (e.g. IMDS 169.254.169.254) for the actual
912        /// connect.
913        ///
914        /// Test setup: bind a real TCP listener on 127.0.0.1, then
915        /// configure `NetworkResolver` with a custom `DnsResolver`
916        /// that maps `pinned.test` → that listener.  If ureq uses
917        /// our pinned IP, the connection reaches the listener and
918        /// the body comes back.  If ureq does its own DNS lookup
919        /// of `pinned.test` (which doesn't resolve in real DNS),
920        /// the request fails — that's the TOCTOU-still-open
921        /// signal.
922        #[test]
923        fn pins_verified_ip_into_agent_resolver() {
924            use std::io::{Read, Write};
925            use std::net::{SocketAddr, TcpListener};
926            use std::sync::Arc;
927
928            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
929            let port = listener.local_addr().unwrap().port();
930
931            // Accept one connection, drain the request, send a
932            // minimal HTTP response.  Joined at the end so any
933            // panic from the server thread surfaces.
934            let server = std::thread::spawn(move || {
935                let (mut stream, _peer) = listener.accept().unwrap();
936                let mut buf = [0u8; 1024];
937                let _ = stream.read(&mut buf);
938                stream.write_all(
939                    b"HTTP/1.1 200 OK\r\n\
940                      Content-Length: 5\r\n\
941                      Content-Type: application/xml\r\n\
942                      \r\n\
943                      hello"
944                ).unwrap();
945            });
946
947            #[derive(Debug)]
948            struct FakeDns { port: u16 }
949            impl DnsResolver for FakeDns {
950                fn lookup(&self, host: &str, _port: u16) -> Vec<SocketAddr> {
951                    if host == "pinned.test" {
952                        vec![SocketAddr::from(([127, 0, 0, 1], self.port))]
953                    } else { vec![] }
954                }
955            }
956
957            let r = NetworkResolver::new(["pinned.test".to_string()])
958                .with_plaintext_http()
959                .with_private_ips_allowed()  // listener is on loopback
960                .with_dns_resolver(Arc::new(FakeDns { port }));
961
962            let bytes = r.resolve(None, &format!("http://pinned.test:{port}/foo.dtd"), None)
963                .expect("ureq should connect to the pinned IP, not re-resolve via real DNS");
964            assert_eq!(bytes, b"hello");
965            server.join().unwrap();
966        }
967
968        #[test]
969        fn allows_plaintext_http_when_opted_in() {
970            // Build the resolver, request http://; this should
971            // pass the scheme + host checks (would attempt a
972            // network call so we expect either Refused on the
973            // private-IP check OR Io on the actual HTTP failure
974            // — never a scheme refusal).
975            let r = NetworkResolver::new(["localhost".to_string()])
976                .with_plaintext_http()
977                .with_private_ips_allowed();   // localhost is loopback
978            // Don't actually run the network call — call check_url
979            // logic indirectly by verifying no scheme rejection.
980            // (Public surface only exposes resolve() which DOES
981            // call out; we accept either Refused for non-scheme
982            // reasons or Io.)
983            let err = r.resolve(None, "http://localhost/foo.dtd", None).unwrap_err();
984            // Should NOT be a scheme refusal — that's the bit we
985            // want to verify the with_plaintext_http() builder
986            // unblocked.
987            if let ResolveError::Refused(msg) = &err {
988                assert!(!msg.contains("scheme"),
989                    "should not refuse on scheme when plaintext is opted in: {msg}");
990            }
991            // Either Io (couldn't connect) or another Refused is
992            // fine — we're only checking the scheme gate.
993        }
994    }
995}