Skip to content

[Bug]: --internal (hostOnly) networks allow arbitrary outbound TCP; isolation test passes only because DNS fails #2062

Description

@rc979

I have done the following

  • 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:

    container network create --internal isolated-test
  2. Confirm the mode:

    container network inspect isolated-test | grep mode
    #       "mode" : "hostOnly",
  3. Confirm the container has a single NIC, one default route, and no proxy configuration:

    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:

    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:

    # 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.swifthostOnly: "Containers can talk with each other in the same subnet only."
  • Isolated network #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."
  • [Request]: Option to prevent host access on internal networks #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:

// 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<Int32> = [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:

// 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:

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

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions