Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions host/src/bin/pyhl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,11 +37,19 @@ fn parse_mount(spec: &str) -> Result<Preopen> {
Preopen::parse_cli(spec).map_err(|e| anyhow!("invalid --mount {:?}: {}", spec, e))
}

fn build_network_policy(net: bool, net_allow: &[String]) -> Result<Option<NetworkPolicy>> {
fn build_network_policy(
net: bool,
net_allow: &[String],
net_block: &[String],
) -> Result<Option<NetworkPolicy>> {
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 {
Expand Down Expand Up @@ -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<String>,

/// 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<String>,
}

#[derive(Args)]
Expand Down Expand Up @@ -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<String>,

/// 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<String>,

/// Print evolve/warmup/per-run timing to stderr. Off by default so the
/// user's script output is clean.
#[arg(short = 'v', long = "verbose")]
Expand Down Expand Up @@ -359,7 +393,7 @@ fn cmd_setup(args: SetupArgs) -> Result<()> {
.iter()
.map(|m| parse_mount(m))
.collect::<Result<_>>()?;
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();
Expand Down Expand Up @@ -497,7 +531,7 @@ fn cmd_run(args: RunArgs) -> Result<()> {
.iter()
.map(|m| parse_mount(m))
.collect::<Result<_>>()?;
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);

Expand Down
129 changes: 127 additions & 2 deletions host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<IpAddr>,
hostnames: Vec<String>,
}

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<str>]) -> Result<Self> {
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::<IpAddr>() {
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(())
}
}
}
}
}
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 20 additions & 2 deletions host/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<String>,

/// 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<String>,

/// 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")]
Expand Down Expand Up @@ -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 {
Expand Down
Loading