use std::net::{IpAddr, ToSocketAddrs};
use crate::error::{AgError, AgResult};
#[derive(Clone, Debug)]
pub struct HttpAccessPolicy {
allow_http: bool,
block_loopback: bool,
block_link_local: bool,
block_private: bool,
allow_hosts: Vec<String>,
}
impl Default for HttpAccessPolicy {
fn default() -> Self {
Self {
allow_http: true,
block_loopback: true,
block_link_local: true,
block_private: false,
allow_hosts: Vec::new(),
}
}
}
impl HttpAccessPolicy {
pub fn permissive() -> Self {
Self {
allow_http: true,
block_loopback: false,
block_link_local: false,
block_private: false,
allow_hosts: Vec::new(),
}
}
pub fn strict() -> Self {
Self {
allow_http: false,
block_loopback: true,
block_link_local: true,
block_private: true,
allow_hosts: Vec::new(),
}
}
pub fn allow_http(mut self, yes: bool) -> Self {
self.allow_http = yes;
self
}
pub fn block_private(mut self, yes: bool) -> Self {
self.block_private = yes;
self
}
pub fn allow_host(mut self, host: impl Into<String>) -> Self {
self.allow_hosts.push(host.into().to_ascii_lowercase());
self
}
fn ip_blocked(&self, ip: IpAddr) -> Option<&'static str> {
let ip = match ip {
IpAddr::V6(a) => a.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(IpAddr::V6(a)),
v4 => v4,
};
if ip.is_unspecified() {
return Some("unspecified address");
}
match ip {
IpAddr::V4(a) => {
if self.block_loopback && a.is_loopback() {
return Some("loopback address");
}
if self.block_link_local && a.is_link_local() {
return Some("link-local address");
}
if self.block_private && a.is_private() {
return Some("private address");
}
}
IpAddr::V6(a) => {
if self.block_loopback && a.is_loopback() {
return Some("loopback address");
}
let seg = a.segments()[0];
if self.block_link_local && (seg & 0xffc0) == 0xfe80 {
return Some("link-local address");
}
if self.block_private && (seg & 0xfe00) == 0xfc00 {
return Some("unique-local address");
}
}
}
None
}
}
pub(crate) async fn validate_http_url(
url: &str,
policy: &HttpAccessPolicy,
) -> AgResult<Vec<std::net::SocketAddr>> {
let u = url::Url::parse(url).map_err(|e| AgError::Mosaic(format!("invalid asset url: {e}")))?;
match u.scheme() {
"https" => {}
"http" if policy.allow_http => {}
"http" => {
return Err(AgError::Mosaic(
"plaintext http asset url rejected by access policy".into(),
))
}
other => {
return Err(AgError::Mosaic(format!(
"asset url scheme {other:?} rejected by access policy"
)))
}
}
let ips: Vec<IpAddr> = match u.host() {
Some(url::Host::Ipv4(a)) => vec![IpAddr::V4(a)],
Some(url::Host::Ipv6(a)) => vec![IpAddr::V6(a)],
Some(url::Host::Domain(d)) => {
let port = u.port_or_known_default().unwrap_or(443);
let hostname = d.to_string();
static DNS_LIMITER: std::sync::OnceLock<std::sync::Arc<tokio::sync::Semaphore>> =
std::sync::OnceLock::new();
let limiter = DNS_LIMITER
.get_or_init(|| std::sync::Arc::new(tokio::sync::Semaphore::new(8)))
.clone();
resolve_bounded(limiter, std::time::Duration::from_secs(10), move || {
(hostname.as_str(), port)
.to_socket_addrs()
.map(|it| it.map(|s| s.ip()).collect::<Vec<_>>())
.map_err(|_| AgError::Mosaic("asset host DNS resolution failed".into()))
})
.await?
}
None => return Err(AgError::Mosaic("asset url missing host".into())),
};
if ips.is_empty() {
return Err(AgError::Mosaic(
"asset host DNS returned no addresses".into(),
));
}
let allowlisted = u.host_str().is_some_and(|host| {
policy
.allow_hosts
.iter()
.any(|h| h.eq_ignore_ascii_case(host))
});
for &ip in &ips {
if allowlisted {
continue;
}
if let Some(reason) = policy.ip_blocked(ip) {
return Err(AgError::Mosaic(format!(
"asset host resolves to blocked {reason}"
)));
}
}
Ok(ips
.into_iter()
.map(|ip| std::net::SocketAddr::new(ip, u.port_or_known_default().unwrap_or(443)))
.collect())
}
#[derive(Debug)]
pub(crate) struct PinnedConnector(pub reqwest::Client);
impl object_store::client::HttpConnector for PinnedConnector {
fn connect(
&self,
_: &object_store::ClientOptions,
) -> object_store::Result<object_store::client::HttpClient> {
Ok(object_store::client::HttpClient::new(self.0.clone()))
}
}
pub(crate) async fn checked_http_store(
url: &str,
policy: &HttpAccessPolicy,
) -> AgResult<std::sync::Arc<dyn object_store::ObjectStore>> {
let addresses = validate_http_url(url, policy).await?;
let parsed = url::Url::parse(url).map_err(|_| AgError::Mosaic("invalid asset URL".into()))?;
let url = url.to_owned();
crate::io::cog::build_http_transport(move || {
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.resolve_to_addrs(parsed.host_str().unwrap(), &addresses)
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|_| AgError::Mosaic("HTTP client construction failed".into()))?;
let store = object_store::http::HttpBuilder::new()
.with_url(url)
.with_http_connector(PinnedConnector(client))
.build()
.map_err(|_| AgError::Mosaic("HTTP store construction failed".into()))?;
Ok(std::sync::Arc::new(store) as std::sync::Arc<dyn object_store::ObjectStore>)
})
.await
}
async fn resolve_bounded(
limiter: std::sync::Arc<tokio::sync::Semaphore>,
timeout: std::time::Duration,
resolve: impl FnOnce() -> AgResult<Vec<IpAddr>> + Send + 'static,
) -> AgResult<Vec<IpAddr>> {
tokio::time::timeout(timeout, async move {
let permit = limiter
.acquire_owned()
.await
.map_err(|_| AgError::Mosaic("DNS resolver closed".into()))?;
let request = crate::request::RequestContext::current();
tokio::task::spawn_blocking(move || {
let _permit = permit;
crate::request::RequestContext::run_blocking(request, resolve)
})
.await
.map_err(|_| AgError::Mosaic("DNS resolver task failed".into()))?
})
.await
.map_err(|_| AgError::Mosaic("DNS resolution deadline exceeded".into()))?
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn default_blocks_loopback_and_link_local() {
let p = HttpAccessPolicy::default();
assert!(validate_http_url("http://127.0.0.1/x.tif", &p)
.await
.is_err());
assert!(validate_http_url("http://[::1]/x.tif", &p).await.is_err());
assert!(
validate_http_url("http://169.254.169.254/latest/meta-data/", &p)
.await
.is_err()
);
}
#[tokio::test]
async fn default_allows_private_and_public() {
let p = HttpAccessPolicy::default();
assert!(validate_http_url("http://192.168.1.10:9000/b/x.tif", &p)
.await
.is_ok());
assert!(validate_http_url("http://10.0.0.5/x.tif", &p).await.is_ok());
assert!(validate_http_url("https://example.com/x.tif", &p)
.await
.is_ok());
}
#[tokio::test]
async fn strict_blocks_private_and_plaintext() {
let p = HttpAccessPolicy::strict();
assert!(validate_http_url("http://example.com/x.tif", &p)
.await
.is_err()); assert!(validate_http_url("https://10.0.0.5/x.tif", &p)
.await
.is_err()); assert!(validate_http_url("https://192.168.0.1/x.tif", &p)
.await
.is_err());
}
#[tokio::test]
async fn permissive_allows_loopback() {
let p = HttpAccessPolicy::permissive();
assert!(validate_http_url("http://127.0.0.1:9000/x.tif", &p)
.await
.is_ok());
}
#[tokio::test]
async fn error_text_excludes_query_string() {
let p = HttpAccessPolicy::default();
let msg = validate_http_url("http://127.0.0.1/x.tif?X-Amz-Signature=s3cr3t", &p)
.await
.unwrap_err()
.to_string();
assert!(!msg.contains("s3cr3t"), "credentials leaked: {msg}");
}
#[tokio::test]
async fn allow_host_bypasses_ip_checks() {
let p = HttpAccessPolicy::default().allow_host("localhost");
assert!(validate_http_url("http://localhost:9000/x.tif", &p)
.await
.is_ok());
}
mod dns_failure {
use super::*;
#[tokio::test]
async fn audit_dns_failure_is_closed() {
assert!(validate_http_url(
"https://no-such-host.invalid/x.tif",
&HttpAccessPolicy::default()
)
.await
.is_err());
}
}
mod dns_limit {
use super::*;
#[tokio::test]
async fn dns_timeout_keeps_capacity_until_blocking_resolver_finishes() {
let limiter = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
let (release, wait) = std::sync::mpsc::channel::<()>();
let result = tokio::time::timeout(
std::time::Duration::from_millis(100),
resolve_bounded(
limiter.clone(),
std::time::Duration::from_millis(10),
move || {
let _ = wait.recv();
Ok(vec!["127.0.0.1".parse().unwrap()])
},
),
)
.await;
let available = limiter.available_permits();
let _ = release.send(());
assert!(
matches!(result, Ok(Err(_))),
"resolver must return its own bounded timeout"
);
assert_eq!(
available, 0,
"timeout must not release still-running blocking DNS capacity"
);
let permit = tokio::time::timeout(std::time::Duration::from_secs(1), limiter.acquire())
.await
.unwrap()
.unwrap();
drop(permit);
}
}
}