Skip to content

Commit b221fec

Browse files
committed
net: add BlockList network policy variant
Signed-off-by: danbugs <danilochiarlone@gmail.com>
1 parent 5f424bb commit b221fec

3 files changed

Lines changed: 187 additions & 10 deletions

File tree

host/src/bin/pyhl.rs

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use hyperlight_unikraft::pyhl::{
2626
copy_replace, discover_source_artifacts, extract_from_ghcr, GHCR_INITRD_IMAGE,
2727
GHCR_KERNEL_IMAGE,
2828
};
29-
use hyperlight_unikraft::{AllowList, NetworkPolicy, Preopen, Sandbox};
29+
use hyperlight_unikraft::{AllowList, BlockList, NetworkPolicy, Preopen, Sandbox};
3030
use std::fs;
3131
use std::path::{Path, PathBuf};
3232
use std::time::Instant;
@@ -37,11 +37,19 @@ fn parse_mount(spec: &str) -> Result<Preopen> {
3737
Preopen::parse_cli(spec).map_err(|e| anyhow!("invalid --mount {:?}: {}", spec, e))
3838
}
3939

40-
fn build_network_policy(net: bool, net_allow: &[String]) -> Result<Option<NetworkPolicy>> {
40+
fn build_network_policy(
41+
net: bool,
42+
net_allow: &[String],
43+
net_block: &[String],
44+
) -> Result<Option<NetworkPolicy>> {
4145
if !net_allow.is_empty() {
4246
Ok(Some(NetworkPolicy::AllowList(AllowList::from_hosts(
4347
net_allow,
4448
)?)))
49+
} else if !net_block.is_empty() {
50+
Ok(Some(NetworkPolicy::BlockList(BlockList::from_hosts(
51+
net_block,
52+
)?)))
4553
} else if net {
4654
Ok(Some(NetworkPolicy::AllowAll))
4755
} else {
@@ -164,8 +172,21 @@ struct SetupArgs {
164172

165173
/// Restrict guest networking to the listed hosts/IPs.
166174
/// Implies --net. Repeatable.
167-
#[arg(long = "net-allow", value_name = "HOST_OR_IP")]
175+
#[arg(
176+
long = "net-allow",
177+
value_name = "HOST_OR_IP",
178+
conflicts_with = "net_block"
179+
)]
168180
net_allow: Vec<String>,
181+
182+
/// Block the listed hosts/IPs; all other destinations are allowed.
183+
/// Implies --net. Repeatable.
184+
#[arg(
185+
long = "net-block",
186+
value_name = "HOST_OR_IP",
187+
conflicts_with = "net_allow"
188+
)]
189+
net_block: Vec<String>,
169190
}
170191

171192
#[derive(Args)]
@@ -199,9 +220,22 @@ struct RunArgs {
199220

200221
/// Restrict guest networking to the listed hosts/IPs.
201222
/// Implies --net. Repeatable.
202-
#[arg(long = "net-allow", value_name = "HOST_OR_IP")]
223+
#[arg(
224+
long = "net-allow",
225+
value_name = "HOST_OR_IP",
226+
conflicts_with = "net_block"
227+
)]
203228
net_allow: Vec<String>,
204229

230+
/// Block the listed hosts/IPs; all other destinations are allowed.
231+
/// Implies --net. Repeatable.
232+
#[arg(
233+
long = "net-block",
234+
value_name = "HOST_OR_IP",
235+
conflicts_with = "net_allow"
236+
)]
237+
net_block: Vec<String>,
238+
205239
/// Print evolve/warmup/per-run timing to stderr. Off by default so the
206240
/// user's script output is clean.
207241
#[arg(short = 'v', long = "verbose")]
@@ -359,7 +393,7 @@ fn cmd_setup(args: SetupArgs) -> Result<()> {
359393
.iter()
360394
.map(|m| parse_mount(m))
361395
.collect::<Result<_>>()?;
362-
let network = build_network_policy(args.net, &args.net_allow)?;
396+
let network = build_network_policy(args.net, &args.net_allow, &args.net_block)?;
363397

364398
eprintln!("pyhl: warming up Python and persisting snapshot…");
365399
let t_warm = Instant::now();
@@ -497,7 +531,7 @@ fn cmd_run(args: RunArgs) -> Result<()> {
497531
.iter()
498532
.map(|m| parse_mount(m))
499533
.collect::<Result<_>>()?;
500-
let network = build_network_policy(args.net, &args.net_allow)?;
534+
let network = build_network_policy(args.net, &args.net_allow, &args.net_block)?;
501535

502536
let initrd = home.join(INITRD_FILE);
503537

host/src/lib.rs

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ pub enum NetworkPolicy {
168168
AllowAll,
169169
/// Only connections to the listed destinations are permitted.
170170
AllowList(AllowList),
171+
/// All connections are allowed *except* to the listed destinations.
172+
BlockList(BlockList),
171173
}
172174

173175
/// A set of allowed network destinations.
@@ -234,19 +236,91 @@ impl AllowList {
234236
}
235237
}
236238

239+
/// A set of blocked network destinations.
240+
///
241+
/// Like [`AllowList`], stores both literal IPs and hostnames. At check
242+
/// time, hostnames are re-resolved so the policy tracks DNS changes.
243+
#[derive(Clone, Debug)]
244+
pub struct BlockList {
245+
blocked_ips: HashSet<IpAddr>,
246+
hostnames: Vec<String>,
247+
}
248+
249+
impl BlockList {
250+
/// Build a blocklist from a mixed set of hostnames and IP literals.
251+
///
252+
/// Hostnames are verified to be resolvable at construction time
253+
/// (fail-closed). At check time they are re-resolved so CDN/anycast
254+
/// rotation doesn't cause false passes.
255+
pub fn from_hosts(entries: &[impl AsRef<str>]) -> Result<Self> {
256+
use std::net::ToSocketAddrs;
257+
let mut blocked_ips = HashSet::new();
258+
let mut hostnames = Vec::new();
259+
for entry in entries {
260+
let entry = entry.as_ref();
261+
if let Ok(ip) = entry.parse::<IpAddr>() {
262+
blocked_ips.insert(ip);
263+
} else {
264+
let addrs = (entry, 0u16)
265+
.to_socket_addrs()
266+
.map_err(|e| anyhow!("resolve {:?}: {}", entry, e))?;
267+
let mut found = false;
268+
for sa in addrs {
269+
blocked_ips.insert(sa.ip());
270+
found = true;
271+
}
272+
if !found {
273+
return Err(anyhow!("hostname {:?} resolved to zero addresses", entry));
274+
}
275+
hostnames.push(entry.to_string());
276+
}
277+
}
278+
Ok(Self {
279+
blocked_ips,
280+
hostnames,
281+
})
282+
}
283+
284+
fn is_blocked(&self, ip: &IpAddr) -> bool {
285+
if self.blocked_ips.contains(ip) {
286+
return true;
287+
}
288+
use std::net::ToSocketAddrs;
289+
for host in &self.hostnames {
290+
if let Ok(addrs) = (host.as_str(), 0u16).to_socket_addrs() {
291+
for sa in addrs {
292+
if &sa.ip() == ip {
293+
return true;
294+
}
295+
}
296+
}
297+
}
298+
false
299+
}
300+
}
301+
237302
impl NetworkPolicy {
238303
fn check(&self, addr: &std::net::SocketAddr) -> Result<()> {
239304
match self {
240305
NetworkPolicy::AllowAll => Ok(()),
241306
NetworkPolicy::AllowList(al) => {
242-
// Allow DNS (port 53) — hostname-based allowlists need
243-
// the guest to reach DNS servers for resolution.
244307
if addr.port() == 53 || al.is_allowed(&addr.ip()) {
245308
Ok(())
246309
} else {
247310
Err(anyhow!("network policy denies connection to {}", addr))
248311
}
249312
}
313+
NetworkPolicy::BlockList(bl) => {
314+
// DNS (port 53) is always allowed so the guest can resolve names.
315+
if addr.port() == 53 {
316+
return Ok(());
317+
}
318+
if bl.is_blocked(&addr.ip()) {
319+
Err(anyhow!("network policy denies connection to {}", addr))
320+
} else {
321+
Ok(())
322+
}
323+
}
250324
}
251325
}
252326
}
@@ -2468,6 +2542,57 @@ mod tests {
24682542
assert!(result.is_err());
24692543
}
24702544

2545+
#[test]
2546+
fn network_policy_blocklist_permits_unlisted_ip() {
2547+
let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap();
2548+
let policy = NetworkPolicy::BlockList(bl);
2549+
let addr: std::net::SocketAddr = "5.6.7.8:443".parse().unwrap();
2550+
assert!(policy.check(&addr).is_ok());
2551+
}
2552+
2553+
#[test]
2554+
fn network_policy_blocklist_denies_listed_ip() {
2555+
let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap();
2556+
let policy = NetworkPolicy::BlockList(bl);
2557+
let addr: std::net::SocketAddr = "1.2.3.4:80".parse().unwrap();
2558+
let err = policy.check(&addr).unwrap_err();
2559+
assert!(err.to_string().contains("network policy denies"), "{err}");
2560+
}
2561+
2562+
#[test]
2563+
fn network_policy_blocklist_allows_dns() {
2564+
let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap();
2565+
let policy = NetworkPolicy::BlockList(bl);
2566+
let addr: std::net::SocketAddr = "1.2.3.4:53".parse().unwrap();
2567+
assert!(policy.check(&addr).is_ok());
2568+
}
2569+
2570+
#[test]
2571+
fn blocklist_resolves_hostnames() {
2572+
let bl = BlockList::from_hosts(&["localhost"]).unwrap();
2573+
assert!(
2574+
bl.is_blocked(&"127.0.0.1".parse().unwrap()) || bl.is_blocked(&"::1".parse().unwrap())
2575+
);
2576+
}
2577+
2578+
#[test]
2579+
fn blocklist_rejects_unresolvable_hostname() {
2580+
let result = BlockList::from_hosts(&["this.host.definitely.does.not.exist.example"]);
2581+
assert!(result.is_err());
2582+
}
2583+
2584+
#[test]
2585+
fn net_tools_registered_with_blocklist() {
2586+
let mut tools = ToolRegistry::new();
2587+
let exit_code = Arc::new(AtomicI32::new(0));
2588+
let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap();
2589+
register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::BlockList(bl)));
2590+
let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#;
2591+
let resp = tools.dispatch(req);
2592+
let s = std::str::from_utf8(&resp).unwrap();
2593+
assert!(s.contains("\"fd\""), "net_socket should work: {s}");
2594+
}
2595+
24712596
#[test]
24722597
fn net_tools_not_registered_without_policy() {
24732598
let mut tools = ToolRegistry::new();

host/src/main.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
99
use anyhow::Result;
1010
use clap::Parser;
11-
use hyperlight_unikraft::{parse_memory, AllowList, NetworkPolicy, Preopen, Sandbox};
11+
use hyperlight_unikraft::{parse_memory, AllowList, BlockList, NetworkPolicy, Preopen, Sandbox};
1212
use std::path::PathBuf;
1313

1414
#[derive(Parser, Debug)]
@@ -65,9 +65,23 @@ struct Args {
6565
/// Restrict guest networking to the listed hosts/IPs.
6666
/// Implies --net. Hostnames are resolved at sandbox creation time.
6767
/// Repeatable: `--net-allow api.github.com --net-allow 10.0.0.1`.
68-
#[arg(long = "net-allow", value_name = "HOST_OR_IP")]
68+
#[arg(
69+
long = "net-allow",
70+
value_name = "HOST_OR_IP",
71+
conflicts_with = "net_block"
72+
)]
6973
net_allow: Vec<String>,
7074

75+
/// Block the listed hosts/IPs; all other destinations are allowed.
76+
/// Implies --net. Hostnames are resolved at sandbox creation time.
77+
/// Repeatable: `--net-block evil.com --net-block 10.0.0.1`.
78+
#[arg(
79+
long = "net-block",
80+
value_name = "HOST_OR_IP",
81+
conflicts_with = "net_allow"
82+
)]
83+
net_block: Vec<String>,
84+
7185
/// Run the application N additional times via snapshot/restore + call.
7286
/// The first run always happens. --repeat=2 means 3 total runs.
7387
#[arg(long, default_value = "0")]
@@ -167,6 +181,10 @@ fn main() -> Result<()> {
167181
Some(NetworkPolicy::AllowList(AllowList::from_hosts(
168182
&args.net_allow,
169183
)?))
184+
} else if !args.net_block.is_empty() {
185+
Some(NetworkPolicy::BlockList(BlockList::from_hosts(
186+
&args.net_block,
187+
)?))
170188
} else if args.net {
171189
Some(NetworkPolicy::AllowAll)
172190
} else {

0 commit comments

Comments
 (0)