use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::sync::Arc;
use ferrijs_permissions::{Container, Denied, Permissions, is_metadata_ip, is_private_ip};
type BoxErr = Box<dyn std::error::Error + Send + Sync>;
pub trait NetPolicy: Send + Sync + std::fmt::Debug {
fn check(&self, host: &str, port: Option<u16>) -> Result<(), Denied>;
}
impl NetPolicy for Permissions {
fn check(&self, host: &str, port: Option<u16>) -> Result<(), Denied> {
self.check_net(host, port)
}
}
impl NetPolicy for Container {
fn check(&self, host: &str, port: Option<u16>) -> Result<(), Denied> {
self.check_net(host, port)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuardError {
Invalid(String),
Blocked(String),
Denied(Denied),
}
impl std::fmt::Display for GuardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(m) | Self::Blocked(m) => f.write_str(m),
Self::Denied(d) => d.fmt(f),
}
}
}
impl std::error::Error for GuardError {}
#[derive(Debug, Clone, Default)]
pub struct NetGuard {
pub policy: Option<Arc<dyn NetPolicy>>,
pub block_metadata: bool,
pub block_private: bool,
}
impl NetGuard {
#[must_use]
pub fn is_active(&self) -> bool {
self.policy.is_some() || self.block_metadata || self.block_private
}
#[must_use]
pub(crate) fn dns_filter(&self) -> Option<(bool, bool)> {
(self.block_metadata || self.block_private).then_some((self.block_metadata, self.block_private))
}
}
fn ip_blocked(ip: IpAddr, block_metadata: bool, block_private: bool) -> bool {
(block_metadata && is_metadata_ip(ip)) || (block_private && is_private_ip(ip))
}
pub fn check_url(url: &reqwest::Url, g: &NetGuard) -> Result<(), GuardError> {
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
return Err(GuardError::Invalid(format!(
"scheme \"{scheme}\" is not permitted by the sandbox network policy"
)));
}
let host = url
.host_str()
.ok_or_else(|| GuardError::Invalid("request to a URL with no host is not permitted".to_string()))?;
if let Ok(ip) = host.parse::<IpAddr>()
&& ip_blocked(ip, g.block_metadata, g.block_private)
{
return Err(GuardError::Blocked(format!(
"request to blocked address {ip} (sandbox network policy)"
)));
}
if let Some(policy) = &g.policy {
policy
.check(
host.trim_start_matches('[').trim_end_matches(']'),
url.port_or_known_default(),
)
.map_err(GuardError::Denied)?;
}
Ok(())
}
pub fn preflight(resolved_url: &str, g: &NetGuard) -> Result<(), GuardError> {
match reqwest::Url::parse(resolved_url) {
Ok(u) => check_url(&u, g),
Err(_) => Err(GuardError::Invalid(format!(
"request to invalid/relative URL \"{resolved_url}\" is not permitted by the sandbox network policy"
))),
}
}
pub(crate) struct GuardedResolver {
pub block_metadata: bool,
pub block_private: bool,
}
impl reqwest::dns::Resolve for GuardedResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let host = name.as_str().to_string();
let (bm, bp) = (self.block_metadata, self.block_private);
Box::pin(async move {
let lookup = tokio::task::spawn_blocking(move || -> std::io::Result<Vec<SocketAddr>> {
Ok((host.as_str(), 0u16).to_socket_addrs()?.collect())
})
.await;
let addrs = match lookup {
Ok(Ok(a)) => a,
Ok(Err(e)) => return Err(Box::new(e) as BoxErr),
Err(e) => return Err(Box::new(e) as BoxErr),
};
let kept: Vec<SocketAddr> = addrs.into_iter().filter(|sa| !ip_blocked(sa.ip(), bm, bp)).collect();
if kept.is_empty() {
return Err("all resolved addresses blocked by sandbox network policy".into());
}
Ok(Box::new(kept.into_iter()) as reqwest::dns::Addrs)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn only(hosts: &[&str]) -> Arc<dyn NetPolicy> {
Arc::new(Permissions::none().allow_net(hosts.iter().copied()).unwrap())
}
#[test]
fn check_url_blocks_metadata_by_default_keeps_loopback() {
let g = NetGuard {
policy: None,
block_metadata: true,
block_private: false,
};
assert!(check_url(&reqwest::Url::parse("http://169.254.169.254/").unwrap(), &g).is_err());
assert!(check_url(&reqwest::Url::parse("http://127.0.0.1:9/").unwrap(), &g).is_ok());
assert!(check_url(&reqwest::Url::parse("file:///etc/passwd").unwrap(), &g).is_err());
}
#[test]
fn check_url_enforces_policy_on_any_url() {
let g = NetGuard {
policy: Some(only(&["allowed.com"])),
block_metadata: true,
block_private: false,
};
assert!(check_url(&reqwest::Url::parse("https://allowed.com/x").unwrap(), &g).is_ok());
assert!(matches!(
check_url(&reqwest::Url::parse("https://evil.com/x").unwrap(), &g),
Err(GuardError::Denied(_))
));
assert!(check_url(&reqwest::Url::parse("https://allowed.com@evil.com/x").unwrap(), &g).is_err());
}
#[test]
fn check_url_applies_the_scheme_default_port() {
let g = NetGuard {
policy: Some(only(&["allowed.com:443"])),
..Default::default()
};
assert!(check_url(&reqwest::Url::parse("https://allowed.com/").unwrap(), &g).is_ok());
assert!(check_url(&reqwest::Url::parse("http://allowed.com/").unwrap(), &g).is_err());
assert!(check_url(&reqwest::Url::parse("https://allowed.com:8443/").unwrap(), &g).is_err());
}
#[test]
fn preflight_fails_closed_on_unparsable_url() {
let g = NetGuard {
policy: Some(only(&["allowed.com"])),
block_metadata: true,
block_private: false,
};
assert!(preflight("not a url", &g).is_err());
}
#[test]
fn inert_guard_is_not_active() {
assert!(!NetGuard::default().is_active());
assert!(
NetGuard {
block_metadata: true,
..Default::default()
}
.is_active()
);
}
}