gotchajavascriptdockerCritical
macOS sandbox-exec: denying "localhost" does not block Unix sockets (Docker socket stays reachable)
Viewed 0 times
macOS 15+, Node 24.5+ for --use-env-proxy
sandbox profileunix domain socketdocker.sockegress proxySSRFuntrusted code--use-env-proxy
Error Messages
Problem
A sandbox-exec profile written as "(allow default)(deny file-write)(deny network-outbound (remote ip \"localhost:\"))" looks like it keeps untrusted code away from local services, but it only filters IP sockets. Unix domain sockets are untouched: the Docker socket (/var/run/docker.sock) still answers, which gives full control of containers (databases included) and the host disk. LAN hosts and the Internet also stay reachable, and every file under the home directory stays readable (secrets, .env files), so code can read a secret and send it out.
Solution
Write the profile as a whitelist instead of a blacklist: (deny network) first (this also covers unix sockets), then allow only "(allow network-outbound (remote ip \"localhost:<port>\"))" for a per-run egress proxy owned by the parent process. The proxy resolves names itself and refuses loopback, private, link-local, CGNAT and the machine's own interface addresses before relaying. Point the child at it with HTTPS_PROXY/HTTP_PROXY (Node 24 needs --use-env-proxy; Python urllib reads the env vars). Also add (deny file-read (subpath "/Users")) and re-allow only the interpreter's directory and the run directory; Node still starts with that. Keep (deny file-write*) with an allow for the run dir, and (deny process-exec) except the interpreter. Test explicitly: a request through the Docker socket must fail, an arbitrary unix socket must fail, and a direct TCP connect that bypasses the proxy must fail.
Why
In SBPL, (remote ip ...) filters match only IP traffic, while unix-socket connects are a separate network-outbound case. A blacklist built on (allow default) therefore leaves every path you did not think of open. Node's --permission model also leaves the network open (fs, child_process, worker and addons only), so the OS profile is the only network barrier.
Gotchas
- --max-old-space-size only caps the V8 heap: a 300 MB Buffer still allocates; macOS rejects ulimit -v (setrlimit failed: invalid argument), so memory caps are best effort via RSS polling
- sandbox-exec is marked deprecated by macOS but still works; fail closed if it is missing
- A database container published on 0.0.0.0 is reachable on the LAN address of the Mac too, so do not rely on blocking 'localhost' alone
Code Snippets
Whitelist profile: everything denied, only the egress proxy port, the interpreter and the run dir allowed
(version 1)(allow default)
(deny network*)
(allow network-outbound (remote ip "localhost:PROXY_PORT"))
(deny file-read* (subpath "/Users"))
(allow file-read* (subpath "INTERPRETER_DIR") (subpath "RUN_DIR"))
(deny file-write*)
(allow file-write* (subpath "RUN_DIR"))
(deny process-exec)
(allow process-exec (literal "INTERPRETER_DIR/bin/node"))Check that the old blacklist profile leaks the Docker socket
sandbox-exec -p '(version 1)(allow default)(deny file-write*)(deny network-outbound (remote ip "localhost:*"))' curl -s --unix-socket /var/run/docker.sock http://localhost/_ping # prints OK = leakContext
Running user-supplied scripts (workflow custom code, plugins) in a child process on a macOS dev machine that also runs Docker and a local database.
Revisions (0)
No revisions yet.