diff --git a/host/src/bin/pyhl.rs b/host/src/bin/pyhl.rs index 9c83571..3589030 100644 --- a/host/src/bin/pyhl.rs +++ b/host/src/bin/pyhl.rs @@ -26,7 +26,7 @@ use hyperlight_unikraft::pyhl::{ copy_replace, discover_source_artifacts, extract_from_ghcr, GHCR_INITRD_IMAGE, GHCR_KERNEL_IMAGE, }; -use hyperlight_unikraft::{AllowList, NetworkPolicy, Preopen, Sandbox}; +use hyperlight_unikraft::{AllowList, BlockList, NetworkPolicy, Preopen, Sandbox}; use std::fs; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -37,11 +37,19 @@ fn parse_mount(spec: &str) -> Result { Preopen::parse_cli(spec).map_err(|e| anyhow!("invalid --mount {:?}: {}", spec, e)) } -fn build_network_policy(net: bool, net_allow: &[String]) -> Result> { +fn build_network_policy( + net: bool, + net_allow: &[String], + net_block: &[String], +) -> Result> { if !net_allow.is_empty() { Ok(Some(NetworkPolicy::AllowList(AllowList::from_hosts( net_allow, )?))) + } else if !net_block.is_empty() { + Ok(Some(NetworkPolicy::BlockList(BlockList::from_hosts( + net_block, + )?))) } else if net { Ok(Some(NetworkPolicy::AllowAll)) } else { @@ -164,8 +172,21 @@ struct SetupArgs { /// Restrict guest networking to the listed hosts/IPs. /// Implies --net. Repeatable. - #[arg(long = "net-allow", value_name = "HOST_OR_IP")] + #[arg( + long = "net-allow", + value_name = "HOST_OR_IP", + conflicts_with = "net_block" + )] net_allow: Vec, + + /// Block the listed hosts/IPs; all other destinations are allowed. + /// Implies --net. Repeatable. + #[arg( + long = "net-block", + value_name = "HOST_OR_IP", + conflicts_with = "net_allow" + )] + net_block: Vec, } #[derive(Args)] @@ -199,9 +220,22 @@ struct RunArgs { /// Restrict guest networking to the listed hosts/IPs. /// Implies --net. Repeatable. - #[arg(long = "net-allow", value_name = "HOST_OR_IP")] + #[arg( + long = "net-allow", + value_name = "HOST_OR_IP", + conflicts_with = "net_block" + )] net_allow: Vec, + /// Block the listed hosts/IPs; all other destinations are allowed. + /// Implies --net. Repeatable. + #[arg( + long = "net-block", + value_name = "HOST_OR_IP", + conflicts_with = "net_allow" + )] + net_block: Vec, + /// Print evolve/warmup/per-run timing to stderr. Off by default so the /// user's script output is clean. #[arg(short = 'v', long = "verbose")] @@ -359,7 +393,7 @@ fn cmd_setup(args: SetupArgs) -> Result<()> { .iter() .map(|m| parse_mount(m)) .collect::>()?; - let network = build_network_policy(args.net, &args.net_allow)?; + let network = build_network_policy(args.net, &args.net_allow, &args.net_block)?; eprintln!("pyhl: warming up Python and persisting snapshot…"); let t_warm = Instant::now(); @@ -497,7 +531,7 @@ fn cmd_run(args: RunArgs) -> Result<()> { .iter() .map(|m| parse_mount(m)) .collect::>()?; - let network = build_network_policy(args.net, &args.net_allow)?; + let network = build_network_policy(args.net, &args.net_allow, &args.net_block)?; let initrd = home.join(INITRD_FILE); diff --git a/host/src/lib.rs b/host/src/lib.rs index 54c7366..718bc44 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -168,6 +168,8 @@ pub enum NetworkPolicy { AllowAll, /// Only connections to the listed destinations are permitted. AllowList(AllowList), + /// All connections are allowed *except* to the listed destinations. + BlockList(BlockList), } /// A set of allowed network destinations. @@ -234,19 +236,91 @@ impl AllowList { } } +/// A set of blocked network destinations. +/// +/// Like [`AllowList`], stores both literal IPs and hostnames. At check +/// time, hostnames are re-resolved so the policy tracks DNS changes. +#[derive(Clone, Debug)] +pub struct BlockList { + blocked_ips: HashSet, + hostnames: Vec, +} + +impl BlockList { + /// Build a blocklist from a mixed set of hostnames and IP literals. + /// + /// Hostnames are verified to be resolvable at construction time + /// (fail-closed). At check time they are re-resolved so CDN/anycast + /// rotation doesn't cause false passes. + pub fn from_hosts(entries: &[impl AsRef]) -> Result { + use std::net::ToSocketAddrs; + let mut blocked_ips = HashSet::new(); + let mut hostnames = Vec::new(); + for entry in entries { + let entry = entry.as_ref(); + if let Ok(ip) = entry.parse::() { + blocked_ips.insert(ip); + } else { + let addrs = (entry, 0u16) + .to_socket_addrs() + .map_err(|e| anyhow!("resolve {:?}: {}", entry, e))?; + let mut found = false; + for sa in addrs { + blocked_ips.insert(sa.ip()); + found = true; + } + if !found { + return Err(anyhow!("hostname {:?} resolved to zero addresses", entry)); + } + hostnames.push(entry.to_string()); + } + } + Ok(Self { + blocked_ips, + hostnames, + }) + } + + fn is_blocked(&self, ip: &IpAddr) -> bool { + if self.blocked_ips.contains(ip) { + return true; + } + use std::net::ToSocketAddrs; + for host in &self.hostnames { + if let Ok(addrs) = (host.as_str(), 0u16).to_socket_addrs() { + for sa in addrs { + if &sa.ip() == ip { + return true; + } + } + } + } + false + } +} + impl NetworkPolicy { fn check(&self, addr: &std::net::SocketAddr) -> Result<()> { match self { NetworkPolicy::AllowAll => Ok(()), NetworkPolicy::AllowList(al) => { - // Allow DNS (port 53) — hostname-based allowlists need - // the guest to reach DNS servers for resolution. if addr.port() == 53 || al.is_allowed(&addr.ip()) { Ok(()) } else { Err(anyhow!("network policy denies connection to {}", addr)) } } + NetworkPolicy::BlockList(bl) => { + // DNS (port 53) is always allowed so the guest can resolve names. + if addr.port() == 53 { + return Ok(()); + } + if bl.is_blocked(&addr.ip()) { + Err(anyhow!("network policy denies connection to {}", addr)) + } else { + Ok(()) + } + } } } } @@ -2468,6 +2542,57 @@ mod tests { assert!(result.is_err()); } + #[test] + fn network_policy_blocklist_permits_unlisted_ip() { + let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap(); + let policy = NetworkPolicy::BlockList(bl); + let addr: std::net::SocketAddr = "5.6.7.8:443".parse().unwrap(); + assert!(policy.check(&addr).is_ok()); + } + + #[test] + fn network_policy_blocklist_denies_listed_ip() { + let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap(); + let policy = NetworkPolicy::BlockList(bl); + let addr: std::net::SocketAddr = "1.2.3.4:80".parse().unwrap(); + let err = policy.check(&addr).unwrap_err(); + assert!(err.to_string().contains("network policy denies"), "{err}"); + } + + #[test] + fn network_policy_blocklist_allows_dns() { + let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap(); + let policy = NetworkPolicy::BlockList(bl); + let addr: std::net::SocketAddr = "1.2.3.4:53".parse().unwrap(); + assert!(policy.check(&addr).is_ok()); + } + + #[test] + fn blocklist_resolves_hostnames() { + let bl = BlockList::from_hosts(&["localhost"]).unwrap(); + assert!( + bl.is_blocked(&"127.0.0.1".parse().unwrap()) || bl.is_blocked(&"::1".parse().unwrap()) + ); + } + + #[test] + fn blocklist_rejects_unresolvable_hostname() { + let result = BlockList::from_hosts(&["this.host.definitely.does.not.exist.example"]); + assert!(result.is_err()); + } + + #[test] + fn net_tools_registered_with_blocklist() { + let mut tools = ToolRegistry::new(); + let exit_code = Arc::new(AtomicI32::new(0)); + let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap(); + register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::BlockList(bl))); + let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; + let resp = tools.dispatch(req); + let s = std::str::from_utf8(&resp).unwrap(); + assert!(s.contains("\"fd\""), "net_socket should work: {s}"); + } + #[test] fn net_tools_not_registered_without_policy() { let mut tools = ToolRegistry::new(); diff --git a/host/src/main.rs b/host/src/main.rs index 869061e..7a8d653 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -8,7 +8,7 @@ use anyhow::Result; use clap::Parser; -use hyperlight_unikraft::{parse_memory, AllowList, NetworkPolicy, Preopen, Sandbox}; +use hyperlight_unikraft::{parse_memory, AllowList, BlockList, NetworkPolicy, Preopen, Sandbox}; use std::path::PathBuf; #[derive(Parser, Debug)] @@ -65,9 +65,23 @@ struct Args { /// Restrict guest networking to the listed hosts/IPs. /// Implies --net. Hostnames are resolved at sandbox creation time. /// Repeatable: `--net-allow api.github.com --net-allow 10.0.0.1`. - #[arg(long = "net-allow", value_name = "HOST_OR_IP")] + #[arg( + long = "net-allow", + value_name = "HOST_OR_IP", + conflicts_with = "net_block" + )] net_allow: Vec, + /// Block the listed hosts/IPs; all other destinations are allowed. + /// Implies --net. Hostnames are resolved at sandbox creation time. + /// Repeatable: `--net-block evil.com --net-block 10.0.0.1`. + #[arg( + long = "net-block", + value_name = "HOST_OR_IP", + conflicts_with = "net_allow" + )] + net_block: Vec, + /// Run the application N additional times via snapshot/restore + call. /// The first run always happens. --repeat=2 means 3 total runs. #[arg(long, default_value = "0")] @@ -167,6 +181,10 @@ fn main() -> Result<()> { Some(NetworkPolicy::AllowList(AllowList::from_hosts( &args.net_allow, )?)) + } else if !args.net_block.is_empty() { + Some(NetworkPolicy::BlockList(BlockList::from_hosts( + &args.net_block, + )?)) } else if args.net { Some(NetworkPolicy::AllowAll) } else {