allow-only flag: --allow as a strict whitelist

This change controls the allowed outgoing traffic CIDRs. Instead of

 * send traffic from its own MAC-address
 * send traffic from the IP-address assigned to it by the DHCP
 * send traffic to globally routable IPv4 addresses
 * send traffic to gateway IP of the vmnet bridge
 * receive any incoming traffic

by passing --allow-only, the VM now only:

 * sends traffic from its own MAC-address (unchanged)
 * sends traffic from the IP-address assigned to it by the DHCP (unchanged)
 * sends traffic to the IPv4 CIDRs provided via `--allow`
 * sends/receives DHCP and DNS packets (unchanged)
 * communicates with the vmnet gateway (unchanged)
 * receives any inbound traffic (unchanged)

Everything else that previously counted as “globally routable” is now
blocked.
This commit is contained in:
Elliot Kroo 2025-10-16 17:23:17 -07:00
parent b122b49b3c
commit 3cd14500f8
4 changed files with 37 additions and 9 deletions

View File

@ -10,6 +10,10 @@ It is essentially a userspace packet filter which restricts the VM networking an
* send traffic to gateway IP of the vmnet bridge (this would normally be \"bridge100\" interface)
* receive any incoming traffic
You can widen access to additional private ranges by passing a comma-separated list of CIDRs via `--allow`.
If you'd like to treat `--allow` as a strict whitelist, add `--allow-only`. With that flag, the VM can only reach the CIDRs you specified (plus the inevitable DHCP/DNS traffic and the host gateway needed for connectivity).
In addition, Softnet tunes macOS built-in DHCP server to decrease its lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes). This is especially important when you use Tart to clone and run a lot of ephemeral VMs over a period of one day.
Please check out [this blog post](https://cirrus-ci.org/blog/2022/07/07/isolating-network-between-tarts-macos-virtual-machines/) for backstory.

View File

@ -26,6 +26,7 @@ pub struct Proxy<'proxy> {
vm_mac_address: smoltcp::wire::EthernetAddress,
dhcp_snooper: DhcpSnooper,
allow: PrefixSet<Ipv4Net>,
allow_only: bool,
enobufs_encountered: bool,
port_forwarder: PortForwarder,
}
@ -36,10 +37,17 @@ impl Proxy<'_> {
vm_mac_address: MacAddress,
vm_net_type: NetType,
allow: PrefixSet<Ipv4Net>,
allow_only: bool,
exposed_ports: Vec<ExposedPort>,
) -> Result<Proxy<'proxy>> {
let vm = VM::new(vm_fd)?;
let host = Host::new(vm_net_type, !allow.contains(&Ipv4Net::zero()))?;
let enable_isolation = if allow_only {
true
} else {
!allow.contains(&Ipv4Net::zero())
};
let host = Host::new(vm_net_type, enable_isolation)?;
let poller = Poller::new(vm.as_raw_fd(), host.as_raw_fd())?;
Ok(Proxy {
@ -49,6 +57,7 @@ impl Proxy<'_> {
vm_mac_address: smoltcp::wire::EthernetAddress(vm_mac_address.bytes()),
dhcp_snooper: Default::default(),
allow,
allow_only,
enobufs_encountered: false,
port_forwarder: PortForwarder::new(exposed_ports),
})

View File

@ -61,22 +61,24 @@ impl Proxy<'_> {
fn allowed_from_vm_ipv4(&self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> {
// Have we learned the VM's IP from the DHCP snooping?
if let Some(lease) = &self.dhcp_snooper.lease() {
// If so, allow all global traffic
let dst_addr = ipv4_pkt.dst_addr();
let dst_is_global = ip_network::IpNetwork::from(dst_addr).is_global();
if lease.valid_ip_source(ipv4_pkt.src_addr()) && dst_is_global {
return Some(());
}
// Also allow all traffic to the user-specified CIDRs
// Allow traffic explicitly permitted by the user-specified CIDRs
let dst_net = Ipv4Net::from(dst_addr);
// Use get_lpm() instead of get_spm() to work around prefix-trie
// not handling prefixes like 0.0.0.0/0 correctly[1]
//
// [1]: https://github.com/tiborschneider/prefix-trie/issues/8
if self.allow.get_lpm(&dst_net).is_some() {
if lease.valid_ip_source(ipv4_pkt.src_addr()) && self.allow.get_lpm(&dst_net).is_some()
{
return Some(());
}
if !self.allow_only
&& lease.valid_ip_source(ipv4_pkt.src_addr())
&& ip_network::IpNetwork::from(dst_addr).is_global()
{
return Some(());
}
}

View File

@ -59,6 +59,12 @@ struct Args {
)]
allow: Vec<Ipv4Net>,
#[clap(
long,
help = "if specified, only allow traffic to the CIDRs passed via --allow (besides host/DHCP/DNS)"
)]
allow_only: bool,
#[clap(
long,
help = "comma-separated list of TCP ports to expose (e.g. --expose 2222:22,8080:80)",
@ -171,12 +177,19 @@ fn try_main() -> anyhow::Result<()> {
// Set bootpd(8) min/max lease time while still having the root privileges
set_bootpd_lease_time(args.bootpd_lease_time);
if args.allow_only && args.allow.is_empty() {
return Err(anyhow!(
"--allow-only requires at least one CIDR supplied via --allow"
));
}
// Initialize the proxy while still having the root privileges
let mut proxy = Proxy::new(
args.vm_fd as RawFd,
args.vm_mac_address,
args.vm_net_type,
PrefixSet::from_iter(args.allow),
args.allow_only,
args.expose,
)
.context("failed to initialize proxy")?;