[Bug]: --internal (hostOnly) networks allow arbitrary outbound TCP; isolation test passes only because DNS fails
- Dominant language
- Swift
- Stars
- 49.9k
- Forks
- 1.8k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 22
Description
## I have done the following
- [x] I have searched the existing issues
- [ ] If possible, I've reproduced the issue using the 'main' branch of this project
*(Reproduced on the 1.2.0 signed release. The relevant code paths on `main` appear unchanged — see "Root cause" below.)*
---
## Steps to reproduce
1. Create a host-only network:
```bash
container network create --internal isolated-test
```
2. Confirm the mode:
```bash
container network inspect isolated-test | grep mode
# "mode" : "hostOnly",
```
3. Confirm the container has a single NIC, one default route, and no proxy configuration:
```bash
container run --rm --network isolated-test alpine/curl \
sh -c 'ip -o addr show | grep -v " lo "; ip route; env | grep -i proxy || echo "(no proxy vars)"'
```
```
2: eth0 inet 192.168.250.2/24 scope global eth0
default via 192.168.250.1 dev eth0
192.168.250.0/24 dev eth0 scope link src 192.168.250.2
(no proxy vars)
```
4. Reach the public internet over TCP, addressing hosts by IP:
```bash
container run --rm --network isolated-test alpine/curl \
curl -sSk --connect-timeout 8 -o /dev/null -w "http=%{http_code}\n" \
-H "Host: github.com" https://140.82.121.4/
```
```
http=200
```
5. Confirm the traffic is NAT'd out of the host, not blocked:
```bash
# from the isolated network
container run --rm --network isolated-test alpine/curl \
curl -sS --noproxy '*' https://1.1.1.1/cdn-cgi/trace | grep ^ip=
# ip=[REDACTED — host's public IP]
# from the default NAT network, for comparison
container run --rm alpine/curl \
curl -sS --noproxy '*' https://1.1.1.1/cdn-cgi/trace | grep ^ip=
# ip=[REDACTED — identical]
```
Both networks egress with the same public source address.
### Scope of the leak
Outbound **TCP is fully open**. UDP and ICMP are not forwarded.
| Test from the hostOnly network | Result |
| --- | --- |
| `https://140.82.121.4` (GitHub, `-k`) | `HTTP 200` |
| `https://151.101.1.140` (Fastly, `-k`) | `HTTP 421` (real server response) |
| `https://1.1.1.1`, `1.0.0.1`, `8.8.8.8`, `9.9.9.9` | `301` / `301` / `302` / `404` |
| `nslookup example.com 8.8.8.8` (UDP/53) | timeout |
| `ping 8.8.8.8` (ICMP) | 100% loss |
---
## Problem description
`--internal` is documented and implemented as an isolation boundary, but it does not block egress. A container on a hostOnly network can open TCP connections to any internet host, NAT'd behind the host's public IP.
The intended behavior is stated consistently in four places:
- `NetworkMode.swift` — `hostOnly`: *"Containers can talk with each other in the same subnet only."*
- #1079, which introduced the flag — *"Containers under the same subnet can connect each other (and the host), **but not the external network**."*
- `container network create --help` — *"**Restrict** to host-only network."*
- #1320 describes the current behavior as *"creates a network with no internet access"* — that premise does not hold.
### Why this has gone undetected
The integration test that covers this asserts isolation by resolving a **hostname**:
```swift
// Tests/IntegrationTests/Network/TestCLINetwork.swift
// External connection should be blocked — the isolated network has no gateway.
let externalResult = try f.run([
"run", "--rm", "--network", net, curlImage,
"curl", "--connect-timeout", "5", "http://google.com",
])
let hostOnlyBlockedCodes: Set = [6, 7, 28]
#expect(hostOnlyBlockedCodes.contains(externalResult.status), ...)
```
DNS is known not to work on host-only networks (noted by @jglogan in discussion #1170), so this test terminates on curl exit **6 — "couldn't resolve host"** — which is the UDP path failing, not the TCP path being blocked. On the same network, in the same moment:
```
curl http://google.com → exit 6 ← scored as "blocked"
curl https://140.82.121.4/ → HTTP 200 ← actual egress
```
The test would pass unchanged with TCP egress wide open. #1552 later widened the assertion from `== 6` to `{6, 7, 28}` in response to flakes, which further decoupled it from what it is meant to verify.
The missing UDP/ICMP translation and the "DNS doesn't work on host-only networks" limitation are the same underlying gap — and that gap is precisely what conceals the open TCP path.
The test comment also states "the isolated network has no gateway", but a gateway is configured unconditionally (`gateway = ipv4Subnet.lower + 1`), and the guest receives it as its default route.
### Root cause
The only isolation mechanism is the vmnet operating mode:
```swift
// Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift:114
let mode: vmnet.operating_modes_t = configuration.mode == .hostOnly
? .VMNET_HOST_MODE : .VMNET_SHARED_MODE
```
There is no packet filter rule, no route suppression, and no other enforcement anywhere in the vmnet plugin. The guarantee rests entirely on `VMNET_HOST_MODE` suppressing forwarding, which does not hold for TCP on this configuration. The host has `net.inet.ip.forwarding: 1` and holds `192.168.250.1` on `bridge100`.
### Impact
`--internal` combined with a dual-homed proxy is the isolation pattern recommended to users sandboxing autonomous coding agents (discussion #1170, and #1320 / #719). Under that pattern the sandboxed workload can bypass the proxy entirely by connecting to any destination by IP address. Users following the documented guidance are getting materially less containment than the flag advertises.
### Suggested fix
1. **Enforcement** — apply an explicit egress block for hostOnly subnets rather than relying on `VMNET_HOST_MODE` alone, or plumb through a vmnet configuration that reliably suppresses forwarding.
2. **Test** — assert against a raw IP so the check exercises the TCP path rather than DNS:
```swift
// Reaching a literal IP proves egress is open regardless of DNS state.
let externalResult = try f.run([
"run", "--rm", "--network", net, curlImage,
"curl", "-sSk", "--connect-timeout", "5", "https://1.1.1.1/",
])
#expect(externalResult.status != 0, "hostOnly network must not reach external IPs")
```
Retaining a hostname-based check as well is fine, but it cannot be the only signal.
---
## Environment
- **OS**: macOS 26.5 (25F71), Apple silicon (arm64)
- **Xcode**: n/a — Command Line Tools only (`/Library/Developer/CommandLineTools`)
- **Container**: Container CLI version 1.2.0 (build: release, commit: 6e65319)
---
## Code of Conduct
- [x] I agree to follow this project's Code of Conduct
Contributor guide
Research direction
Start with Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift around the hostOnly operating-mode selection, then read Tests/IntegrationTests/Network/TestCLINetwork.swift and its external-connection assertion. Reproduce the test using a literal external IP so DNS failure cannot determine the result. Done means hostOnly networks block external TCP egress and the integration test detects that behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- macos, swift
- Domain
- networking, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100