#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::catalog::Catalog;
pub trait EntityResolver: Send + Sync + std::fmt::Debug {
fn resolve(
&self,
public_id: Option<&str>,
system_id: &str,
base_uri: Option<&str>,
) -> Result<Vec<u8>, ResolveError>;
}
#[derive(Debug)]
pub enum ResolveError {
Refused(String),
Io(String),
Other(String),
}
impl std::fmt::Display for ResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResolveError::Refused(s) => write!(f, "resolver refused: {s}"),
ResolveError::Io(s) => write!(f, "resolver I/O error: {s}"),
ResolveError::Other(s) => write!(f, "resolver error: {s}"),
}
}
}
impl std::error::Error for ResolveError {}
#[derive(Debug, Clone)]
pub struct FilesystemResolver {
catalog: Option<Catalog>,
allowed_roots: Vec<PathBuf>,
}
impl FilesystemResolver {
pub fn new(allowed_roots: Vec<PathBuf>) -> Self {
Self { catalog: None, allowed_roots }
}
pub fn with_catalog(mut self, catalog: Catalog) -> Self {
self.catalog = Some(catalog);
self
}
}
impl FilesystemResolver {
pub(crate) fn validate_path(
&self,
public_id: Option<&str>,
system_id: &str,
) -> Result<PathBuf, ResolveError> {
let target = self.catalog.as_ref()
.and_then(|c| c.resolve(public_id, Some(system_id)))
.map(|c| c.into_owned())
.unwrap_or_else(|| system_id.to_string());
let path_str = if let Some(rest) = target.strip_prefix("file://") {
rest
} else if target.contains("://") {
return Err(ResolveError::Refused(format!(
"FilesystemResolver only handles file:// URIs, got: {target}"
)));
} else {
&target
};
let path = PathBuf::from(path_str);
let canonical = path.canonicalize().map_err(|_| {
ResolveError::Refused(format!(
"path {} is outside the configured allowed roots",
path.display()
))
})?;
if !self.allowed_roots.iter().any(|root| {
root.canonicalize()
.map(|cr| canonical.starts_with(&cr))
.unwrap_or(false)
}) {
return Err(ResolveError::Refused(format!(
"path {} is outside the configured allowed roots",
path.display()
)));
}
Ok(canonical)
}
pub(crate) fn read_validated(
&self,
canonical: &std::path::Path,
) -> Result<Vec<u8>, ResolveError> {
use std::io::Read;
let file = open_no_follow(canonical).map_err(|e| ResolveError::Io(format!(
"reading {}: {e}", canonical.display()
)))?;
let mut buf = Vec::new();
let mut reader = file;
reader.read_to_end(&mut buf).map_err(|e| ResolveError::Io(format!(
"reading {}: {e}", canonical.display()
)))?;
Ok(buf)
}
}
fn open_no_follow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
#[cfg(any(target_os = "linux", target_os = "android"))]
const O_NOFOLLOW: i32 = 0o400000;
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd",
target_os = "netbsd", target_os = "openbsd", target_os = "dragonfly"))]
const O_NOFOLLOW: i32 = 0x0100;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(O_NOFOLLOW)
.open(path)
}
#[cfg(not(unix))]
{
std::fs::File::open(path)
}
}
impl EntityResolver for FilesystemResolver {
fn resolve(
&self,
public_id: Option<&str>,
system_id: &str,
_base_uri: Option<&str>,
) -> Result<Vec<u8>, ResolveError> {
let canonical = self.validate_path(public_id, system_id)?;
self.read_validated(&canonical)
}
}
#[derive(Debug)]
pub struct ChainedResolver {
resolvers: Vec<Arc<dyn EntityResolver>>,
}
impl ChainedResolver {
pub fn new(resolvers: Vec<Arc<dyn EntityResolver>>) -> Self {
Self { resolvers }
}
}
impl EntityResolver for ChainedResolver {
fn resolve(
&self,
public_id: Option<&str>,
system_id: &str,
base_uri: Option<&str>,
) -> Result<Vec<u8>, ResolveError> {
let mut last_refused: Option<ResolveError> = None;
for r in &self.resolvers {
match r.resolve(public_id, system_id, base_uri) {
Ok(bytes) => return Ok(bytes),
Err(e @ ResolveError::Refused(_)) => {
last_refused = Some(e);
continue;
}
Err(other) => return Err(other),
}
}
Err(last_refused.unwrap_or_else(|| ResolveError::Refused(
"no resolvers in chain".to_string()
)))
}
}
#[derive(Debug, Default, Clone)]
pub struct InMemoryResolver {
by_system_id: HashMap<String, Vec<u8>>,
by_public_id: HashMap<String, Vec<u8>>,
}
impl InMemoryResolver {
pub fn new() -> Self { Self::default() }
pub fn with_system(mut self, system_id: &str, bytes: Vec<u8>) -> Self {
self.by_system_id.insert(system_id.to_string(), bytes);
self
}
pub fn with_public(mut self, public_id: &str, bytes: Vec<u8>) -> Self {
self.by_public_id.insert(public_id.to_string(), bytes);
self
}
}
impl EntityResolver for InMemoryResolver {
fn resolve(
&self,
public_id: Option<&str>,
system_id: &str,
_base_uri: Option<&str>,
) -> Result<Vec<u8>, ResolveError> {
if let Some(p) = public_id {
if let Some(b) = self.by_public_id.get(p) {
return Ok(b.clone());
}
}
if let Some(b) = self.by_system_id.get(system_id) {
return Ok(b.clone());
}
Err(ResolveError::Refused(format!(
"InMemoryResolver has no entry for system_id={system_id:?} public_id={public_id:?}"
)))
}
}
#[cfg(feature = "network-resolver")]
mod network {
use super::*;
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::Duration;
pub(crate) trait DnsResolver: Send + Sync + std::fmt::Debug {
fn lookup(&self, host: &str, port: u16) -> Vec<std::net::SocketAddr>;
}
#[derive(Debug, Default)]
pub(crate) struct StdDnsResolver;
impl DnsResolver for StdDnsResolver {
fn lookup(&self, host: &str, port: u16) -> Vec<std::net::SocketAddr> {
use std::net::ToSocketAddrs;
(host, port).to_socket_addrs()
.map(|iter| iter.collect())
.unwrap_or_default()
}
}
pub struct NetworkResolver {
allowed_hosts: HashSet<String>,
block_private_ips: bool,
allow_plaintext_http: bool,
max_response_bytes: usize,
timeout: Duration,
cache: Mutex<lru::Cache>,
dns: std::sync::Arc<dyn DnsResolver>,
}
impl std::fmt::Debug for NetworkResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NetworkResolver")
.field("allowed_hosts", &self.allowed_hosts)
.field("block_private_ips", &self.block_private_ips)
.field("allow_plaintext_http", &self.allow_plaintext_http)
.field("max_response_bytes", &self.max_response_bytes)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
impl NetworkResolver {
pub fn new<I: IntoIterator<Item = String>>(allowed_hosts: I) -> Self {
Self {
allowed_hosts: allowed_hosts.into_iter().collect(),
block_private_ips: true,
allow_plaintext_http: false,
max_response_bytes: 1 * 1024 * 1024, timeout: Duration::from_secs(10),
cache: Mutex::new(lru::Cache::new(64 * 1024 * 1024)),
dns: std::sync::Arc::new(StdDnsResolver),
}
}
#[cfg(test)]
pub(crate) fn with_dns_resolver(mut self, dns: std::sync::Arc<dyn DnsResolver>) -> Self {
self.dns = dns;
self
}
pub fn with_plaintext_http(mut self) -> Self {
self.allow_plaintext_http = true;
self
}
pub fn with_private_ips_allowed(mut self) -> Self {
self.block_private_ips = false;
self
}
pub fn with_max_response_bytes(mut self, n: usize) -> Self {
self.max_response_bytes = n;
self
}
pub fn with_timeout(mut self, d: Duration) -> Self {
self.timeout = d;
self
}
pub fn with_cache_size(mut self, max_total_bytes: usize) -> Self {
self.cache = Mutex::new(lru::Cache::new(max_total_bytes));
self
}
fn check_url(&self, url: &str)
-> Result<(String, u16, Vec<std::net::SocketAddr>), ResolveError>
{
let (scheme, rest) = url.split_once("://").ok_or_else(|| {
ResolveError::Refused(format!("URL {url:?} missing scheme://"))
})?;
let default_port: u16 = match scheme {
"https" => 443,
"http" if self.allow_plaintext_http => 80,
other => return Err(ResolveError::Refused(format!(
"scheme {other:?} not allowed (use with_plaintext_http() to permit http://)"
))),
};
let auth = rest.split(|c: char| matches!(c, '/' | '?' | '#'))
.next().unwrap_or("");
if auth.is_empty() {
return Err(ResolveError::Refused(
format!("URL {url:?} has no host component")
));
}
if auth.contains('@') {
return Err(ResolveError::Refused(format!(
"URLs with userinfo (user@host) are not supported by NetworkResolver"
)));
}
let (host, port) = match auth.rsplit_once(':') {
Some((h, p)) => {
if h.starts_with('[') {
(auth, default_port)
} else {
let port = p.parse::<u16>().map_err(|e| ResolveError::Refused(
format!("invalid port in URL {url:?}: {e}")
))?;
(h, port)
}
}
None => (auth, default_port),
};
if !self.allowed_hosts.contains(host) {
return Err(ResolveError::Refused(format!(
"host {host:?} is not in the allowed-hosts list"
)));
}
let addrs = self.dns.lookup(host, port);
if self.block_private_ips {
for sa in &addrs {
let ip = sa.ip();
if is_private_or_loopback(&ip) {
return Err(ResolveError::Refused(format!(
"host {host:?} resolves to private/loopback IP {ip} \
(use with_private_ips_allowed() to permit)"
)));
}
}
}
Ok((host.to_string(), port, addrs))
}
}
impl EntityResolver for NetworkResolver {
fn resolve(
&self,
_public_id: Option<&str>,
system_id: &str,
_base_uri: Option<&str>,
) -> Result<Vec<u8>, ResolveError> {
if let Some(bytes) = self.cache.lock().unwrap().get(system_id) {
return Ok(bytes);
}
let (_host, _port, verified_addrs) = self.check_url(system_id)?;
use std::io::Read;
let pinned = verified_addrs.clone();
let agent = ureq::AgentBuilder::new()
.timeout(self.timeout)
.resolver(move |_addr: &str| -> std::io::Result<Vec<std::net::SocketAddr>> {
Ok(pinned.clone())
})
.build();
let resp = agent.get(system_id).call()
.map_err(|e| ResolveError::Io(format!("HTTP request failed: {e}")))?;
let reader = resp.into_reader();
let mut limited = reader.take(self.max_response_bytes as u64 + 1);
let mut bytes = Vec::with_capacity(8 * 1024);
limited.read_to_end(&mut bytes)
.map_err(|e| ResolveError::Io(format!("reading response body: {e}")))?;
if bytes.len() > self.max_response_bytes {
return Err(ResolveError::Refused(format!(
"response body exceeds max_response_bytes ({})",
self.max_response_bytes
)));
}
self.cache.lock().unwrap().insert(system_id.to_string(), bytes.clone());
Ok(bytes)
}
}
fn is_private_or_loopback(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_private() || v4.is_loopback() || v4.is_link_local()
|| v4.is_unspecified() || v4.is_broadcast()
}
IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified()
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (v6.segments()[0] & 0xffc0) == 0xfe80
}
}
}
mod lru {
use std::collections::VecDeque;
pub(super) struct Cache {
cap: usize,
cur: usize,
entries: VecDeque<(String, Vec<u8>)>,
}
impl Cache {
pub(super) fn new(cap: usize) -> Self {
Self { cap, cur: 0, entries: VecDeque::new() }
}
pub(super) fn get(&mut self, key: &str) -> Option<Vec<u8>> {
let pos = self.entries.iter().position(|(k, _)| k == key)?;
let entry = self.entries.remove(pos)?;
let bytes = entry.1.clone();
self.entries.push_back(entry);
Some(bytes)
}
pub(super) fn insert(&mut self, key: String, value: Vec<u8>) {
self.cur += value.len();
self.entries.push_back((key, value));
while self.cur > self.cap {
if let Some((_, v)) = self.entries.pop_front() {
self.cur -= v.len();
} else { break; }
}
}
}
}
}
#[cfg(feature = "network-resolver")]
pub use network::NetworkResolver;
#[cfg(all(test, feature = "network-resolver"))]
use network::DnsResolver;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fs_resolver_refuses_path_outside_roots() {
let r = FilesystemResolver::new(vec![std::env::temp_dir()]);
let err = r.resolve(None, "file:///etc/passwd", None).unwrap_err();
assert!(matches!(err, ResolveError::Refused(_)),
"expected Refused, got {err:?}");
}
#[test]
fn fs_resolver_refuses_non_file_scheme() {
let r = FilesystemResolver::new(vec![std::env::temp_dir()]);
let err = r.resolve(None, "https://example.com/foo.dtd", None).unwrap_err();
match err {
ResolveError::Refused(msg) => assert!(msg.contains("file://")),
other => panic!("expected Refused about file://, got {other:?}"),
}
}
#[test]
fn fs_resolver_loads_from_allowed_root() {
let dir = std::env::temp_dir();
let path = dir.join("sup-xml-fs-resolver-test.dtd");
std::fs::write(&path, b"<!-- a test DTD -->").unwrap();
let r = FilesystemResolver::new(vec![dir.clone()]);
let bytes = r.resolve(None, &format!("file://{}", path.display()), None)
.expect("should load successfully");
assert_eq!(bytes, b"<!-- a test DTD -->");
let _ = std::fs::remove_file(&path);
}
#[test]
#[cfg(unix)]
fn fs_resolver_refuses_symlink_swap_between_validate_and_read() {
use std::os::unix::fs::symlink;
let allowed = std::env::temp_dir()
.join(format!("sup-xml-fs-toctou-allowed-{}", std::process::id()));
std::fs::create_dir_all(&allowed).unwrap();
let inside = allowed.join("inner.dtd");
std::fs::write(&inside, b"INSIDE").unwrap();
let outside = std::env::temp_dir()
.join(format!("sup-xml-fs-toctou-secret-{}.dtd", std::process::id()));
std::fs::write(&outside, b"SECRET").unwrap();
let r = FilesystemResolver::new(vec![allowed.clone()]);
let system_id = format!("file://{}", inside.display());
let canonical = r.validate_path(None, &system_id)
.expect("validate should pass for legitimate file inside allowed root");
std::fs::remove_file(&inside).unwrap();
symlink(&outside, &inside).unwrap();
let result = r.read_validated(&canonical);
let _ = std::fs::remove_file(&inside);
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_file(&outside);
match result {
Ok(bytes) => assert_ne!(
bytes, b"SECRET",
"TOCTOU: read followed the swapped symlink and returned out-of-root content"
),
Err(_) => {} }
}
#[test]
fn fs_resolver_consults_catalog_first() {
let dir = std::env::temp_dir();
let real_path = dir.join("sup-xml-fs-resolver-cat-test.dtd");
std::fs::write(&real_path, b"loaded via catalog").unwrap();
let cat_xml = format!(r#"<?xml version="1.0"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
<public publicId="-//Test//DTD//EN" uri="file://{}"/>
</catalog>"#, real_path.display());
let cat = Catalog::parse(cat_xml.as_bytes()).unwrap();
let r = FilesystemResolver::new(vec![dir.clone()]).with_catalog(cat);
let bytes = r.resolve(
Some("-//Test//DTD//EN"),
"http://example.com/never-loaded", None,
).unwrap();
assert_eq!(bytes, b"loaded via catalog");
let _ = std::fs::remove_file(&real_path);
}
#[test]
fn in_memory_resolver_round_trip() {
let r = InMemoryResolver::new()
.with_public("-//Test//DTD//EN", b"public-bytes".to_vec())
.with_system("http://example.com/foo.dtd", b"system-bytes".to_vec());
let b = r.resolve(Some("-//Test//DTD//EN"), "http://nope", None).unwrap();
assert_eq!(b, b"public-bytes");
let b = r.resolve(None, "http://example.com/foo.dtd", None).unwrap();
assert_eq!(b, b"system-bytes");
let err = r.resolve(None, "http://other", None).unwrap_err();
assert!(matches!(err, ResolveError::Refused(_)));
}
#[test]
fn chained_falls_through_refused() {
let in_mem = InMemoryResolver::new()
.with_system("foo", b"from-second".to_vec());
let chain = ChainedResolver::new(vec![
Arc::new(InMemoryResolver::new()), Arc::new(in_mem),
]);
let bytes = chain.resolve(None, "foo", None).unwrap();
assert_eq!(bytes, b"from-second");
}
#[test]
fn chained_propagates_io_errors() {
#[derive(Debug)]
struct Failer;
impl EntityResolver for Failer {
fn resolve(&self, _: Option<&str>, _: &str, _: Option<&str>)
-> Result<Vec<u8>, ResolveError> {
Err(ResolveError::Io("simulated network failure".into()))
}
}
let chain = ChainedResolver::new(vec![
Arc::new(Failer),
Arc::new(InMemoryResolver::new().with_system("foo", b"x".to_vec())),
]);
let err = chain.resolve(None, "foo", None).unwrap_err();
assert!(matches!(err, ResolveError::Io(_)));
}
#[test]
fn empty_chain_returns_refused() {
let chain = ChainedResolver::new(vec![]);
let err = chain.resolve(None, "foo", None).unwrap_err();
assert!(matches!(err, ResolveError::Refused(_)));
}
#[cfg(feature = "network-resolver")]
mod network_tests {
use super::*;
#[test]
fn refuses_non_https_by_default() {
let r = NetworkResolver::new(["example.com".to_string()]);
let err = r.resolve(None, "http://example.com/foo.dtd", None).unwrap_err();
match err {
ResolveError::Refused(msg) => assert!(msg.contains("scheme")),
other => panic!("expected Refused about scheme, got {other:?}"),
}
}
#[test]
fn refuses_unknown_host() {
let r = NetworkResolver::new(["allowed.example.com".to_string()]);
let err = r.resolve(None, "https://other.example.com/foo.dtd", None)
.unwrap_err();
match err {
ResolveError::Refused(msg) => assert!(msg.contains("not in the allowed-hosts")),
other => panic!("expected Refused about host allowlist, got {other:?}"),
}
}
#[test]
fn refuses_userinfo_in_url() {
let r = NetworkResolver::new(["example.com".to_string()]);
let err = r.resolve(None, "https://user:pass@example.com/foo.dtd", None)
.unwrap_err();
match err {
ResolveError::Refused(msg) => assert!(msg.contains("userinfo")),
other => panic!("expected Refused about userinfo, got {other:?}"),
}
}
#[test]
fn refuses_malformed_url() {
let r = NetworkResolver::new(["example.com".to_string()]);
let err = r.resolve(None, "not-a-url-at-all", None).unwrap_err();
assert!(matches!(err, ResolveError::Refused(_)));
}
#[test]
fn pins_verified_ip_into_agent_resolver() {
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener};
use std::sync::Arc;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut stream, _peer) = listener.accept().unwrap();
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
stream.write_all(
b"HTTP/1.1 200 OK\r\n\
Content-Length: 5\r\n\
Content-Type: application/xml\r\n\
\r\n\
hello"
).unwrap();
});
#[derive(Debug)]
struct FakeDns { port: u16 }
impl DnsResolver for FakeDns {
fn lookup(&self, host: &str, _port: u16) -> Vec<SocketAddr> {
if host == "pinned.test" {
vec![SocketAddr::from(([127, 0, 0, 1], self.port))]
} else { vec![] }
}
}
let r = NetworkResolver::new(["pinned.test".to_string()])
.with_plaintext_http()
.with_private_ips_allowed() .with_dns_resolver(Arc::new(FakeDns { port }));
let bytes = r.resolve(None, &format!("http://pinned.test:{port}/foo.dtd"), None)
.expect("ureq should connect to the pinned IP, not re-resolve via real DNS");
assert_eq!(bytes, b"hello");
server.join().unwrap();
}
#[test]
fn allows_plaintext_http_when_opted_in() {
let r = NetworkResolver::new(["localhost".to_string()])
.with_plaintext_http()
.with_private_ips_allowed(); let err = r.resolve(None, "http://localhost/foo.dtd", None).unwrap_err();
if let ResolveError::Refused(msg) = &err {
assert!(!msg.contains("scheme"),
"should not refuse on scheme when plaintext is opted in: {msg}");
}
}
}
}