-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The check is one macro at the handle-class layer\n\nThe Node.js permission model adds runtime gates around capabilities the operator wants denied. The C++ implementation pattern is consistent: each native handle class that reaches a privileged operation calls a check macro before dispatching to libuv. For network connections, the macro is `ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS` and the scope is `permission::PermissionScope::kNet`.\n\nIn v25.2.1, `TCPWrap::Connect` (`src/tcp_wrap.cc`) contains the line:\n\n```cpp\nERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS(\n env, permission::PermissionScope::kNet,\n ip_address.ToStringView(), args);\n```\n\nThe IPv4 connect path runs the check before calling `req_wrap->Dispatch(uv_tcp_connect, ...)`. A process started with `--permission` and no `--allow-net` cannot use `net.connect({ host, port })` against a TCP target. The denial is concrete and visible.\n\nTwo files over in the same directory, `src/pipe_wrap.cc`, `PipeWrap::Connect` looked like this in the same release:\n\n```cpp\nvoid PipeWrap::Connect(const FunctionCallbackInfo& args) {\n Environment* env = Environment::GetCurrent(args);\n PipeWrap* wrap;\n ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This());\n CHECK(args[0]->IsObject());\n CHECK(args[1]->IsString());\n Local req_wrap_obj = args[0].As();\n node::Utf8Value name(env->isolate(), args[1]);\n ConnectWrap* req_wrap =\n new ConnectWrap(env, req_wrap_obj,\n AsyncWrap::PROVIDER_PIPECONNECTWRAP);\n int err = req_wrap->Dispatch(\n uv_pipe_connect2, &wrap->handle_, *name,\n name.length(), 0, AfterConnect);\n // ...\n}\n```\n\nNo `ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS`. No reference to `permission::PermissionScope`. The `name` argument is the destination path. `uv_pipe_connect2` opens a Unix Domain Socket. The dispatch happens unconditionally.\n\n`PipeWrap` is the C++ wrap class for libuv pipe handles. It is the type that backs `net.createConnection({ socketPath })`, undici's `Agent` constructed with `connect: { socketPath }`, and any HTTP or TLS client that lets the dispatcher choose a UDS path. Three high-level APIs converge on the same C++ class. None of them traversed a permission check on the way through.\n\n## What the flag was actually gating\n\n`--allow-net` is documented as the capability that unblocks network access when `--permission` is enabled. Operators reading the docs see the word \"network\" and reach for the obvious mental model: this process cannot speak TCP, cannot speak UDP, cannot speak any IPC over a socket.\n\nThe implementation is narrower than the documentation. `--allow-net`, as shipped in v25.2.1, was the absence of `ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS` on TCP-handle dispatch and nothing else. UDP did not have it either. PipeWrap did not have it. The flag named a capability. The runtime checked one of its surfaces.\n\nThe vendor's advisory hedges this by labeling network permissions \"still in the experimental phase\" at the time of disclosure. That hedge is the reason the CVE was scored Medium by the Node.js project and 10.0 (`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H`) by NVD. The gap between those two numbers is the gap between \"we promised this would prevent connections\" and \"we promised this might prevent connections, conditions apply.\"\n\n`--permission` itself is not experimental. It stabilized in December 2024. Operators wiring up production hardening for an Express service trust the flag the way they trust filesystem capabilities. The docs encourage this. The CVE is what happens when one of the gates underneath the flag turns out to be a single line in a single C++ file that someone has to remember to add.\n\n## The PoC chain, and what it actually proves\n\nThe public PoC by `Pauldechassey/CVE-2026-21636` runs the vulnerable process under a documented permission set:\n\n```\nnode --permission --allow-fs-read=/ /app/server.mjs\n```\n\n`server.mjs` exposes one `/language` endpoint:\n\n```js\napp.post('/language', async (req, res) => {\n const requested = req.body?.lang ?? 'fr';\n res.json(await import(requested + '/index.js'));\n});\n```\n\nThe handler does dynamic `import()` on caller-controlled input. Node's `import()` accepts `data:` URLs. The exploit posts `data:text/javascript,//` and gets arbitrary JavaScript execution inside the sandboxed process.\n\nThe author then chains:\n\n1. JS payload calls `process.kill(, 'SIGUSR1')`. Signal dispatch is not gated by `--permission`. A second Node process running unsandboxed receives SIGUSR1 and starts the V8 inspector on `127.0.0.1:9229`.\n2. Second JS payload calls `fetch('http://127.0.0.1:9229/json')` and opens a `WebSocket` to the CDP endpoint.\n3. CDP `Runtime.evaluate` runs `child_process.execSync('cat /app/secret.txt')` inside the unsandboxed target. Arbitrary code execution outside the sandbox.\n\nThe lab's Dockerfile installs `nodejs~=22`. The repo's README admits one detail buried in that choice: the PoC reproduces, in the README's words, \"the concept on v22 where the same bypass is observable.\" Node 22 has neither TCP nor UDS gating because the `--permission` model's net surface had not yet shipped on that line. The chain works because every connection bypasses, not because the v25 CVE primitive fires.\n\nOn v25.2.1, the fetch to `http://127.0.0.1:9229/json` is a TCP connection. It hits `TCPWrap::Connect`. It hits `ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS`. Without `--allow-net`, that connection is denied, and the PoC fails at step three.\n\nThe PoC is honest about what it demonstrates: it is a proof of the chain shape, not the v25 primitive. The v25 primitive lives one architectural layer down. Same JS-injection entry point, same SIGUSR1 stage if the target service exposes one, but the third stage routes through a Unix Domain Socket instead of `127.0.0.1`. The v25 chain calls `net.createConnection({ socketPath: '/var/run/docker.sock' })` and walks the Docker API, or `net.createConnection({ socketPath: '/run/snapd.socket' })` and asks snapd to install a snap, or any other UDS-exposed control plane the host happens to mount into the sandboxed process's filesystem view.\n\n## What the operator actually exposed\n\nA Node service started with `--permission --allow-fs-read=/` and no `--allow-net`, running on a default Linux host or a typical container, has filesystem-level access to:\n\n- `/var/run/docker.sock`, when the container is run with `-v /var/run/docker.sock:/var/run/docker.sock` (the Docker-in-Docker pattern many CI runners and observability sidecars use). Reaching this UDS is reaching `POST /containers/create` followed by `POST /containers//start`, with a `Privileged: true` flag and a `Binds` entry that mounts host `/` into the new container. Container escape primitive, root on host.\n- `/run/snapd.socket`, on Ubuntu hosts where snapd is the package manager. The UDS speaks the snapd REST API. `POST /v2/snaps` with `devmode` accepts an arbitrary snap and installs it as root.\n- `/run/dbus/system_bus_socket`, the system D-Bus. From there, `org.freedesktop.PolicyKit1`, `org.freedesktop.systemd1`, `org.freedesktop.NetworkManager`. PolicyKit-mediated privilege escalation has been a CVE workshop for a decade.\n- `/run/systemd/journal/socket`, journald's structured-log ingest. Log injection at minimum, depending on downstream consumers.\n- `/run/containerd/containerd.sock`, on Kubernetes nodes. Same shape as the Docker socket, larger blast radius.\n\n`--permission` documents itself as a sandbox. None of those UDS endpoints are network in the sense the documentation uses the word. All of them were reachable from inside the sandbox.\n\n## The fix is the missing macro\n\nNode 25.3.0 changes one method:\n\n```diff\n void PipeWrap::Connect(const FunctionCallbackInfo& args) {\n Environment* env = Environment::GetCurrent(args);\n PipeWrap* wrap;\n ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This());\n CHECK(args[0]->IsObject());\n CHECK(args[1]->IsString());\n Local req_wrap_obj = args[0].As();\n node::Utf8Value name(env->isolate(), args[1]);\n+\n+ ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS(\n+ env, permission::PermissionScope::kNet,\n+ name.ToStringView(), args);\n+\n ConnectWrap* req_wrap =\n new ConnectWrap(env, req_wrap_obj,\n AsyncWrap::PROVIDER_PIPECONNECTWRAP);\n int err = req_wrap->Dispatch(\n uv_pipe_connect2, &wrap->handle_, *name,\n name.length(), UV_PIPE_NO_TRUNCATE, AfterConnect);\n```\n\nSame macro that has been in `TCPWrap::Connect` since the network surface stabilized. Same scope: `kNet`. Same call shape. The change is the call site.\n\nThe fix lands at the right layer for the high-level APIs the advisory names. `net.createConnection({ socketPath })`, `tls.connect({ socketPath })`, and undici's UDS-dispatcher path all funnel through libuv's pipe handle, which is wrapped by `PipeWrap`. One line at `PipeWrap::Connect` covers all three. That is what the vendor caught when it framed the CVE as \"via net, tls, or undici/fetch\": the gap was not in three different surfaces, it was in the one C++ class all three reach.\n\n`PipeWrap::Bind` in 25.3.0 still has no permission check. Binding a UDS server (listening on `/tmp/x.sock` for inbound connections) is not gated. Whether that becomes the next CVE depends on what an attacker can do with a sandboxed process that listens but cannot reach.\n\n## The model lives at the handle class\n\nThis is a design-debt-driver. The Node permission model checks at C++ wrap-class call sites. Each native operation that should be gated needs its own macro, in its own file, by the developer who shipped the operation. `TCPWrap::Connect` got the macro. `PipeWrap::Connect` did not, because adding a UDS connection codepath and adding a permission gate to that codepath are two separate edits that have to be remembered together.\n\nThe same shape played out across [Composer's VCS drivers](/posts/composer-perforce-synccodebase-injection): the Hg driver was hardened against shell injection in 2021, the Perforce driver shipped the same primitive for another five years. Different language, different domain, identical mechanism. One fix per surface, no fix at the architecture.\n\nThe next handle class Node ships is the next opportunity for the same omission. QUIC has its own wrap class. HTTP/3 transport has its own wrap class. Whatever the next experimental network feature lands as will have its own wrap class. The model offers no compile-time signal, no test scaffolding that fails when a new wrap class appears without the macro, no design-level enforcement at the libuv boundary or below. The check is convention and code review.\n\nThis is the part of `--permission` the experimental label is doing real work. It lets the project ship surface-by-surface and call each gap a known limitation. CVE-2026-21636 is the gap that escaped the limitations doc and turned into a CVE.\n\nPoC: [Pauldechassey/CVE-2026-21636](https://github.com/Pauldechassey/CVE-2026-21636)","closing_line":"TCPWrap::Connect got the macro. PipeWrap::Connect did not. The next wrap class is the next CVE.","hook_md":"Node 25 ships an experimental permission model. Run a process with `--permission` and no `--allow-net`, and outbound network is denied. The denial is one line in `TCPWrap::Connect`. It was not in `PipeWrap::Connect`. From the perspective of the gated process, the Docker control socket, the snapd socket, the system D-Bus, and journald were not network. CVE-2026-21636.","post_id":62,"slug":"node-cve-2026-21636-uds-is-not-net","title":"CVE-2026-21636: --allow-net Permitted UDS Because UDS Is Not Net","type":"initial","unreadable_sentence":"From the perspective of the gated process, the Docker control socket, the snapd socket, the system D-Bus, and journald were not network."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCajV5WwAKCRDeZjl4jgkQ JnLiAQCjxALAtMITzEGee5Z1m+Zx8xvEg+unL0f9Y8BTedtBWAEAtgBld98o3yc8 dCebLDUusm89FcIKZN7wohi/3a32lQ0= =+MDE -----END PGP SIGNATURE-----