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}