Title: A new net permission model: allow scoping in --allow-net · Issue #67115 · nodejs/node · GitHub
Open Graph Title: A new net permission model: allow scoping in --allow-net · Issue #67115 · nodejs/node
X Title: A new net permission model: allow scoping in --allow-net · Issue #67115 · nodejs/node
Description: Summary Today --allow-net is all-or-nothing. This proposal lets it take a list: --allow-net=api.example.com:443,10.0.0.0/8,*.example.com. The list accepts host names, IPv4, bracketed IPv6, CIDR and *. wildcards, each with an optional por...
Open Graph Description: Summary Today --allow-net is all-or-nothing. This proposal lets it take a list: --allow-net=api.example.com:443,10.0.0.0/8,*.example.com. The list accepts host names, IPv4, bracketed IPv6, CIDR and...
X Description: Summary Today --allow-net is all-or-nothing. This proposal lets it take a list: --allow-net=api.example.com:443,10.0.0.0/8,*.example.com. The list accepts host names, IPv4, bracketed IPv6, CIDR and...
Opengraph URL: https://github.com/nodejs/node/issues/67115
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"A new net permission model: allow scoping in --allow-net","articleBody":"\n## Summary\n\nToday `--allow-net` is all-or-nothing. This proposal lets it take a list: `--allow-net=api.example.com:443,10.0.0.0/8,*.example.com`. The list accepts host names, IPv4, bracketed IPv6, CIDR and `*.` wildcards, each with an optional port.\n\nBare `--allow-net` keeps its current meaning. Checks run in the native layer (TCP, UDP, pipes, DNS, QUIC, DTLS), and a lookup token ties an allowed name to the IPs it resolved to. `process.permission.has('net', 'host:port')` exposes the same check to JS.\n\nThe first iteration has no `--deny-net` and no proxy awareness.\n\n## Motivation and prior art\n\n* Today `net` is all-or-nothing: `--allow-net` grants every host, every port and every socket.\n* Users want least privilege, for example allowing only `api.example.com:443` and their database.\n* Prior art: Deno's `--allow-net=host:port` and `--deny-net`.\n* [nodejs/node#66117](https://github.com/nodejs/node/pull/66117) closed the `Socket(fd)` bypass; only stdio and the IPC channel are exempt.\n\n## Goals\n\n* Limit network access to specific hosts, IPs, CIDR ranges and ports.\n* Bare `--allow-net`, and no `--allow-net` at all, behave exactly as today.\n* Enforce in the native layer for every transport (TCP, UDP, pipes, DNS, QUIC, DTLS), so it can't be bypassed from JS through `_handle`.\n* No cost when the permission model is off; bare `--allow-net` stays a single boolean check.\n* `process.permission.has('net', 'host:port')` gives the same answer as the native check.\n* The config file accepts the list (`\"allow-net\": [\"api.example.com:443\"]`); `true` keeps working.\n* Audit mode reports the denied `host:port`.\n\n## Non-goals (first iteration)\n\n* No `--deny-net`.\n* No proxy awareness: with `HTTP_PROXY` set, the allowed host is the proxy.\n* No change to bare `--allow-net`. Sockets created from an fd ([nodejs/node#66117](https://github.com/nodejs/node/pull/66117)) are allowed only with bare `--allow-net`, since there is no host to match.\n\n## Compatibility and release\n\n* **semver-minor:** an existing flag gains a value form. Bare `--allow-net`, no `--allow-net`, and `\"allow-net\": true` all behave as today.\n* Stability 1.1 would allow semantic changes in a minor, but none are needed.\n* Lands on `main` (v27). v24 and older don't have `--allow-net` (added in v25.0.0).\n* Backport to v26: needs input from the Release WG, since v26 is expected to stay stable.\n\n## Resource model\n\n* `*.example.com` matches names at any depth (RFC 4592), but not `example.com` itself.\n* IPv4-mapped IPv6 addresses are normalized: `::ffff:192.0.2.1` is `192.0.2.1`.\n* A trailing dot is ignored: `example.com.` is `example.com`.\n* Names are case-insensitive (RFC 1035, RFC 4343).\n\n## Surfaces\n\n* **CLI:** `--allow-net=\u003crule>[,\u003crule>...]`. A rule is `host[:port]`, `ip[:port]`, `[ipv6][:port]`, `cidr`, `*.domain[:port]` or `unix:\u003cpath>` (see Q4).\n* **Config file:** `\"permission\": { \"allow-net\": [\"api.example.com:443\"] }`, or `true`.\n* **`process.permission.has('net', 'host:port')`:** same answer as the native check.\n* **`permission.drop('net', rule)`:** only drops a rule exactly as it was granted. `drop('net')` drops everything.\n* **Audit:** the published message names the `host:port`.\n\n## Enforcement\n\n**UDP:**\n\n* Unconnected `send` checks the destination on every packet, since each packet can go to a different host.\n* A connected socket checks once at `connect`; its `send` calls are not checked again.\n\n**DNS:** `dns.resolve*` checks the queried name but creates no token. A connect to an IP is a connect to that IP, not to a host, so it needs an IP entry. Code that also wants a name check can call `process.permission.has('net', 'api.example.com')` itself.\n\n**Custom lookup:** with `--allow-net=api.example.com`, `fetch('https://api.example.com')` works, because Node's own lookup resolves the name. If the code sets its own `lookup` function, for example `new Agent({ connect: { lookup } })` from undici, that function returns the IP instead of Node. Node can't tell whether that IP really belongs to `api.example.com`, so it checks the IP: the request is denied unless the IP is allowed too.\n\n## Security\n\n* **Threat model unchanged:** a seat belt for trusted code, not a sandbox.\n* **What lists add:** trusted code can't accidentally reach a host outside the list.\n* **Known limits:**\n * An allowed name is trusted wherever its DNS answer points, including internal IPs (DNS rebinding).\n * An allowed name's IP can serve other sites through TLS SNI or the Host header (domain fronting).\n * Proxies and fd sockets are covered under Non-goals.\n* **Hardening:** checks are native, so `_handle` can't bypass them, and the token can't be forged.\n\n## Performance\n\n**Principle:** permission mode accepts the cost of checking, and every check is made as cheap as possible. The cost is only paid in list mode; bare `--allow-net` stays a boolean.\n\n**Matchers (first iteration):**\n\n* Exact IPs: a hash set.\n* CIDR: a prefix table.\n* `*.` names: a suffix tree.\n\nThese can change later for performance.\n\n**Generation counter:** each rule add or drop increments a counter per Environment. A token stores the counter value and the rule its host matched. At connect, if the values are equal, only `ip ∈ token` and the port are checked; otherwise a full check runs. The counter is compared before the cached rule is used, and it isn't exposed to JS.\n\n## Rollout and testing\n\n**PR order** (every release stays safe):\n\n1. tcp, udp, pipe and cares checks pass host, port and direction, one PR per transport. The only visible change is more detail in audit messages.\n2. Lookup token, and `connect(..., token)`.\n3. Matchers and the generation counter.\n4. **Switch:** parser, config file, `has()`, `drop`, audit, docs. In list mode, a check with no host denies.\n5. QUIC and DTLS, as separate PRs. Until they land, both deny in list mode.\n\n**Tests:** extend the per-transport files (`test/parallel/test-permission-net-*`). Add bypass tests (`_handle`, custom `lookup`, `::ffff:`, trailing dot, case), `has()` vs native check, a `drop` invalidating tokens, Happy Eyeballs, undici IP + `servername`, config file, audit.\n\n**Benchmark:** `benchmark/net` with permission off, bare, and a list, plus UDP `send`.\n\n## Notes\n\n**Workers:** each worker has its own permission state, built from its own flags. A `drop` only affects the thread that calls it: running workers keep their rules, and new workers start again from the CLI list.\n\n**Child processes:** spawning needs `--allow-child-process`. A Node.js child gets the parent's permission flags through `NODE_OPTIONS`, so the same `--allow-net=\u003clist>` applies, and runtime drops don't carry over. Non-Node children (for example `curl`) aren't restricted at all. The list has to survive `NODE_OPTIONS` quoting, e.g. `unix:` paths that contain spaces.\n\n## Open questions\n\n### Q1: Name vs IP\n\n**Importance:** Critical\n\n**Question:** Hostnames can only be enforced at DNS lookup time (`cares_wrap`); a direct `connect('192.0.2.1')` needs an IP entry. Deno checks names at lookup and links the resolved IPs to connect with a per-socket token; resolution is also used for deny checks. Is that acceptable for Node, or should connect re-resolve and compare?\n\n**Problem:** connect only sees the IP, so a name rule cannot match it. And connect can be reached without a lookup.\n\n**Recommendation:** a native token `{host, ips}`, created by the lookup and checked by connect.\n\n```text\n example.com ▶ cares_wrap ▶ tcp_wrap ──▶ ip in token? ── yes ─▶ check host\n check host (ip+token) └─ no ──▶ check ip\n make token\n\n 192.0.2.1 ───────────────▶ tcp_wrap ──▶ no token ───────────▶ check ip\n (IP literal, custom lookup, _handle)\n```\n\n* **Binding change:** `tcp_wrap.connect(req, ip, port)` → `connect(req, ip, port[, token])`, for both `connect` and `connect6`. The token is optional, so existing calls don't break, but both TCP paths in `net.js` must pass it: `internalConnect` and Happy Eyeballs (`internalConnectMultiple`). Otherwise name rules never match.\n* **Port:** unlike Deno, not in the token. The lookup never sees a port; connect always does. So the lookup matches the host, and connect checks `host:port`.\n\n**Alternatives considered:**\n\n| Option | Result | Why |\n| ------------------------------ | ------ | ------------------------------------------------- |\n| Check the name only at lookup | ✗ | IP literal, custom `lookup` and `_handle` skip it |\n| Re-resolve at connect | ✗ | second answer can differ; extra latency |\n| IP set per Environment | ✗ | an allowed name opens its IPs to every socket |\n| JS-only check | ✗ | bypassed through `_handle` |\n| Native lookup+connect | ✗ | breaks `lookup` and Happy Eyeballs |\n| **Native token `{host, ips}`** | ✓ | per socket, cannot be forged, only in list mode |\n\n### Q2: Connect vs listen\n\n**Importance:** Critical\n\n**Question:** should a host list also control `listen()`?\n\n**Problem:** a listen resource is a local address, not a remote host.\n\n**Options:**\n\n| Option | Example | Pros | Cons |\n| ----------------------- | ------------------------------------------------------ | --------------------------------------------------- | ---------------------------------------------------------- |\n| A. One list | `--allow-net=api.example.com,0.0.0.0:3000` | one grammar, no new flag, listen can be restricted | no connect-only rule; servers must list their bind address |\n| B. Outbound only | `--allow-net=api.example.com` | servers keep working, no listen syntax | listen cannot be restricted |\n| C. Separate listen list | `--allow-net=api.example.com --allow-net-listen=:3000` | explicit direction, each side restricted on its own | new flag; two lists to keep in sync |\n\nPrior art: Deno uses A.\n\nA and C both need `Bind` to pass `addr:port`; today it passes `\"\"`.\n\n### Q3: Port ranges\n\n**Importance:** Critical\n\n**Question:** should `example.com:8000-8100` be supported? Leaning yes.\n\n### Q4: Unix sockets and named pipes\n\n**Importance:** Critical\n\n**Question:** which permission controls a unix socket or named pipe: net, fs, or both?\n\n**Problem:** a unix socket is both a file and a network connection. Today only `kNet` is checked, and bare `--allow-net` opens every socket path.\n\n**Options:**\n\n| Option | Example | Pros | Cons |\n| ------------------------- | ------------------------------------- | --------------------------------------- | --------------------------- |\n| A. net only | `--allow-net=unix:/run/app.sock` | one flag | ignores fs rules |\n| B. fs only | `--allow-fs-read/write=/run/app.sock` | no new syntax | network uncontrolled |\n| C1. both, path in each | fs + `--allow-net=unix:/run/app.sock` | broad fs rules don't open other sockets | path written twice |\n| C2. both, path only in fs | fs + `--allow-net=unix` | shorter | broad fs opens every socket |\n\n**Recommendation:** C1.\n\nAbstract sockets have no file, so only the net rule applies.\n\n### Q5: IPv6 zone\n\n**Importance:** High\n\n**Question:** does `[fe80::1]` match `fe80::1%eth0`?\n\n**Problem:** a link-local address is only unique per interface: `fe80::1%eth0` and `fe80::1%wlan0` can be two different machines. A rule without a zone either matches both (too broad) or neither (useless).\n\n**Options:**\n\n* A. A rule without a zone matches every interface.\n* B. The rule must name the zone: `[fe80::1%eth0]`.\n\n### Q6: CIDR with a port\n\n**Importance:** Medium\n\n**Question:** should `10.0.0.0/8:443` be allowed?\n\n**Problem:** \"any host in my network, only on port 443\" is a real need, but CIDR notation (RFC 4632) has no port form, so this syntax would be our own.\n\n**Options:**\n\n* A. Allow `cidr:port`, for example `10.0.0.0/8:443` and `[2001:db8::/32]:443`.\n* B. A CIDR rule always means all ports.\n\n### Q7: Windows pipe prefix\n\n**Importance:** Medium\n\n**Question:** which prefix names a Windows named pipe in a net rule?\n\n**Problem:** a Windows pipe (`\\\\.\\pipe\\app`) isn't a unix socket, but Node handles both through the same `PipeWrap` (libuv `uv_pipe_t`).\n\n**Options:**\n\n* A. `unix:\\\\.\\pipe\\app`, as in Deno: one prefix, but a misleading name.\n* B. `pipe:\\\\.\\pipe\\app`: a clear name, but two prefixes.\n\n**Recommendation:** A.\n\n### Q8: IDN\n\n**Importance:** Low\n\n**Question:** should `bücher.example` work as a rule?\n\n**Problem:** DNS carries only ASCII, so the name travels as `xn--bcher-kva.example`. If the rule and the lookup are spelled differently, the rule never matches. Converting the name also lets look-alike names (homographs) through.\n\n**Options:**\n\n* A. Convert: `bücher.example` becomes `xn--bcher-kva.example` before matching. Friendly, but look-alike names get through.\n* B. Reject: a non-ASCII rule is an error, and users write the `xn--` form. Simple and safe.\n\n### Q9: Counter width\n\n**Importance:** Low\n\n**Question:** should the generation counter be `uint32` or `uint64`?\n\n**Problem:** if the counter wraps, old tokens become valid again. At a million drops per second, `uint32` wraps in 71.6 minutes; `uint64` takes 585,000 years.\n\n## Future work\n\n* `--deny-net`, with deny checks after resolution (catches a name that resolves to a denied IP).\n* Proxy awareness: check the target host behind `HTTP_PROXY`, not just the proxy.\n* Faster matchers, if benchmarks show a need.\n","author":{"url":"https://github.com/GroophyLifefor","@type":"Person","name":"GroophyLifefor"},"datePublished":"2026-10-29T16:55:55Z","url":"https://github.com/nodejs/node/issues/67115"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:599428b7-9694-63c4-d82f-ca80b7ebd896 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | 92F2:4CC9:397E61:4E0891:6AED1D87 |
| html-safe-nonce | ec5626fed6910f5e55926e6fb5c2cbda4aaa2d54f497ec526c0f6d25ec9a864f |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiI5MkYyOjRDQzk6Mzk3RTYxOjRFMDg5MTo2QUVEMUQ4NyIsInZpc2l0b3JfaWQiOiIyMTkwMTQyMzY0NDg5NDI0MjYzIiwicmVnaW9uX2VkZ2UiOiJpYWQiLCJyZWdpb25fcmVuZGVyIjoiaWFkIn0= |
| visitor-hmac | 39fdaf9afca215318866e2f6f9f9f826883219022dcdf11b495ba9b5494af68b |
| hovercard-subject-tag | issue:6104535560 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/nodejs/node/67115/issue_layout |
| twitter:image | https://opengraph.githubassets.com/e355b10deccbced84ae6c20789dc326295b96ec7cc8aff2a22e3e7dfa709d204/nodejs/node/issues/67115 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/e355b10deccbced84ae6c20789dc326295b96ec7cc8aff2a22e3e7dfa709d204/nodejs/node/issues/67115 |
| og:image:alt | Summary Today --allow-net is all-or-nothing. This proposal lets it take a list: --allow-net=api.example.com:443,10.0.0.0/8,*.example.com. The list accepts host names, IPv4, bracketed IPv6, CIDR and... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | GroophyLifefor |
| hostname | github.com |
| expected-hostname | github.com |
| None | e3e0f7f63e50c6fe879198a93a0a023a648ecc0a5e9bc3b5e033ac57172237e6 |
| turbo-cache-control | no-preview |
| go-import | github.com/nodejs/node git https://github.com/nodejs/node.git |
| octolytics-dimension-user_id | 9950313 |
| octolytics-dimension-user_login | nodejs |
| octolytics-dimension-repository_id | 27193779 |
| octolytics-dimension-repository_nwo | nodejs/node |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 27193779 |
| octolytics-dimension-repository_network_root_nwo | nodejs/node |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 707a24b346ac8a82878b9c084efaa38a997660cb |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width