Skip to content

Loopback rule does not match the IPv4-mapped form ::ffff:127.0.0.1, breaking dual-stack clients (gRPC) #494

Description

@davidjdixon

Summary

With network.allowLocalBinding: true, a sandboxed process can bind, listen on and connect to
loopback using pure 127.0.0.1 or pure ::1. A connect to the IPv4-mapped form
::ffff:127.0.0.1 is refused with EPERM.

That form is what a dual-stack socket (AF_INET6 with IPV6_V6ONLY=0) produces when it connects to
an IPv4 literal. gRPC uses dual-stack sockets, so any gRPC client cannot reach a server in its own
process
inside the sandbox, even though every underlying primitive is permitted.

Reproduction

allowLocalBinding: true, otherwise the shipped defaults.

import errno, socket

srv = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
srv.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)   # dual-stack, as gRPC does
srv.bind(("::", 0)); srv.listen(4)
port = srv.getsockname()[1]

def t(label, fam, addr):
    s = socket.socket(fam, socket.SOCK_STREAM); s.settimeout(5)
    try:
        s.connect((addr, port)); print(f"{label:<32} ok")
    except OSError as e:
        print(f"{label:<32} FAIL errno={e.errno} {errno.errorcode.get(e.errno)}")
    finally:
        s.close()

t("AF_INET  -> 127.0.0.1", socket.AF_INET, "127.0.0.1")
t("AF_INET6 -> ::1", socket.AF_INET6, "::1")
t("AF_INET6 -> ::ffff:127.0.0.1", socket.AF_INET6, "::ffff:127.0.0.1")

Observed under srt --settings <policy> python3 probe.py:

AF_INET  -> 127.0.0.1            ok
AF_INET6 -> ::1                  ok
AF_INET6 -> ::ffff:127.0.0.1     FAIL errno=1 EPERM

Unsandboxed, all three succeed.

Real-world impact

milvus-lite (used by the memsearch tool) starts its gRPC server in-thread and connects to it over
an ephemeral loopback port. Inside the sandbox that connect fails:

FAILED_PRECONDITION: ipv4:127.0.0.1:56075: connect failed:
  addr: ipv4:127.0.0.1:56075 error: Operation not permitted

The server binds successfully (add_insecure_port returns a non-zero port), so this is purely the
client's connect being refused.

What is not the cause

Each ruled out by measurement inside the same policy:

  • Filesystem permissions on the database (granted; failure unchanged)
  • Listening on loopback (bind, listen and accept all succeed)
  • Connect shape: blocking, non-blocking, pre-bound local end, SO_REUSEADDR (all succeed with AF_INET)
  • Non-blocking connect completion via both select and kqueue (both complete)
  • Socket options TCP_NODELAY, SO_KEEPALIVE, SO_REUSEPORT, SO_SNDBUF, SO_NOSIGPIPE (all permitted)
  • The SOCKS proxy environment: fails identically with grpc_proxy, GRPC_PROXY, http_proxy,
    https_proxy and all_proxy cleared, and no_proxy already lists 127.0.0.1
  • gRPC resolver choice: GRPC_DNS_RESOLVER=ares and =native behave the same
  • Policy keys enableWeakerNetworkIsolation (bool), allowAllUnixSockets (bool) and
    allowMachLookup (string[]) — all three tried, all fail

Suggested fix

Have the loopback rule generated for allowLocalBinding also match ::ffff:127.0.0.1 (and the
IPv4-mapped range generally). One change covers every dual-stack client rather than each tool needing
its own workaround.

There is no client-side switch available for gRPC's C core, unlike the JVM's
-Djava.net.preferIPv4Stack=true used for the same problem elsewhere — see grpc/grpc-java#10797 for
the equivalent on the bind side.

Environment

  • macOS 26 (Darwin 25.4.0), Apple silicon
  • @anthropic-ai/sandbox-runtime 0.0.71
  • Python 3.11, grpcio as shipped with pymilvus / milvus-lite

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