A new net permission model: allow scoping in --allow-net

Aperta
#67,115 1 commento 1 reazione 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
18/100
Tipo di issue
Funzionalità
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
javascript
Ambito
backend, security

Direzione di ricerca

Start with the rollout plan and the per-transport tests named as test/parallel/test-permission-net-*; the issue describes changes across native permission checks, TCP, UDP, pipes, DNS, QUIC, DTLS, config, and JavaScript APIs. First read the existing permission checks and one transport test, then compare the open questions and staged PR order. Done means the agreed scope is implemented across the required transports and surfaces, with the listed bypass, config, audit, and permission consistency cases covered.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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 port.

Bare --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.

The first iteration has no --deny-net and no proxy awareness.

Motivation and prior art

  • Today net is all-or-nothing: --allow-net grants every host, every port and every socket.
  • Users want least privilege, for example allowing only api.example.com:443 and their database.
  • Prior art: Deno's --allow-net=host:port and --deny-net.
  • nodejs/node#66117 closed the Socket(fd) bypass; only stdio and the IPC channel are exempt.

Goals

  • Limit network access to specific hosts, IPs, CIDR ranges and ports.
  • Bare --allow-net, and no --allow-net at all, behave exactly as today.
  • Enforce in the native layer for every transport (TCP, UDP, pipes, DNS, QUIC, DTLS), so it can't be bypassed from JS through _handle.
  • No cost when the permission model is off; bare --allow-net stays a single boolean check.
  • process.permission.has('net', 'host:port') gives the same answer as the native check.
  • The config file accepts the list ("allow-net": ["api.example.com:443"]); true keeps working.
  • Audit mode reports the denied host:port.

Non-goals (first iteration)

  • No --deny-net.
  • No proxy awareness: with HTTP_PROXY set, the allowed host is the proxy.
  • No change to bare --allow-net. Sockets created from an fd (nodejs/node#66117) are allowed only with bare --allow-net, since there is no host to match.

Compatibility and release

  • semver-minor: an existing flag gains a value form. Bare --allow-net, no --allow-net, and "allow-net": true all behave as today.
  • Stability 1.1 would allow semantic changes in a minor, but none are needed.
  • Lands on main (v27). v24 and older don't have --allow-net (added in v25.0.0).
  • Backport to v26: needs input from the Release WG, since v26 is expected to stay stable.

Resource model

  • *.example.com matches names at any depth (RFC 4592), but not example.com itself.
  • IPv4-mapped IPv6 addresses are normalized: ::ffff:192.0.2.1 is 192.0.2.1.
  • A trailing dot is ignored: example.com. is example.com.
  • Names are case-insensitive (RFC 1035, RFC 4343).

Surfaces

  • CLI: --allow-net=<rule>[,<rule>...]. A rule is host[:port], ip[:port], [ipv6][:port], cidr, *.domain[:port] or unix:<path> (see Q4).
  • Config file: "permission": { "allow-net": ["api.example.com:443"] }, or true.
  • process.permission.has('net', 'host:port'): same answer as the native check.
  • permission.drop('net', rule): only drops a rule exactly as it was granted. drop('net') drops everything.
  • Audit: the published message names the host:port.

Enforcement

UDP:

  • Unconnected send checks the destination on every packet, since each packet can go to a different host.
  • A connected socket checks once at connect; its send calls are not checked again.

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.

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.

Security

  • Threat model unchanged: a seat belt for trusted code, not a sandbox.
  • What lists add: trusted code can't accidentally reach a host outside the list.
  • Known limits:
    • An allowed name is trusted wherever its DNS answer points, including internal IPs (DNS rebinding).
    • An allowed name's IP can serve other sites through TLS SNI or the Host header (domain fronting).
    • Proxies and fd sockets are covered under Non-goals.
  • Hardening: checks are native, so _handle can't bypass them, and the token can't be forged.

Performance

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.

Matchers (first iteration):

  • Exact IPs: a hash set.
  • CIDR: a prefix table.
  • *. names: a suffix tree.

These can change later for performance.

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.

Rollout and testing

PR order (every release stays safe):

  1. 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.
  2. Lookup token, and connect(..., token).
  3. Matchers and the generation counter.
  4. Switch: parser, config file, has(), drop, audit, docs. In list mode, a check with no host denies.
  5. QUIC and DTLS, as separate PRs. Until they land, both deny in list mode.

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.

Benchmark: benchmark/net with permission off, bare, and a list, plus UDP send.

Notes

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.

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=<list> 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.

Open questions

Q1: Name vs IP

Importance: Critical

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?

Problem: connect only sees the IP, so a name rule cannot match it. And connect can be reached without a lookup.

Recommendation: a native token {host, ips}, created by the lookup and checked by connect.

 example.com ▶ cares_wrap ▶ tcp_wrap ──▶ ip in token? ── yes ─▶ check host
               check host   (ip+token)                └─ no ──▶ check ip
               make token

 192.0.2.1 ───────────────▶ tcp_wrap ──▶ no token ───────────▶ check ip
 (IP literal, custom lookup, _handle)
  • 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.
  • 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.

Alternatives considered:

Option Result Why
Check the name only at lookup ✗ IP literal, custom lookup and _handle skip it
Re-resolve at connect ✗ second answer can differ; extra latency
IP set per Environment ✗ an allowed name opens its IPs to every socket
JS-only check ✗ bypassed through _handle
Native lookup+connect ✗ breaks lookup and Happy Eyeballs
Native token {host, ips} ✓ per socket, cannot be forged, only in list mode
Q2: Connect vs listen

Importance: Critical

Question: should a host list also control listen()?

Problem: a listen resource is a local address, not a remote host.

Options:

Option Example Pros Cons
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
B. Outbound only --allow-net=api.example.com servers keep working, no listen syntax listen cannot be restricted
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

Prior art: Deno uses A.

A and C both need Bind to pass addr:port; today it passes "".

Q3: Port ranges

Importance: Critical

Question: should example.com:8000-8100 be supported? Leaning yes.

Q4: Unix sockets and named pipes

Importance: Critical

Question: which permission controls a unix socket or named pipe: net, fs, or both?

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.

Options:

Option Example Pros Cons
A. net only --allow-net=unix:/run/app.sock one flag ignores fs rules
B. fs only --allow-fs-read/write=/run/app.sock no new syntax network uncontrolled
C1. both, path in each fs + --allow-net=unix:/run/app.sock broad fs rules don't open other sockets path written twice
C2. both, path only in fs fs + --allow-net=unix shorter broad fs opens every socket

Recommendation: C1.

Abstract sockets have no file, so only the net rule applies.

Q5: IPv6 zone

Importance: High

Question: does [fe80::1] match fe80::1%eth0?

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).

Options:

  • A. A rule without a zone matches every interface.
  • B. The rule must name the zone: [fe80::1%eth0].
Q6: CIDR with a port

Importance: Medium

Question: should 10.0.0.0/8:443 be allowed?

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.

Options:

  • A. Allow cidr:port, for example 10.0.0.0/8:443 and [2001:db8::/32]:443.
  • B. A CIDR rule always means all ports.
Q7: Windows pipe prefix

Importance: Medium

Question: which prefix names a Windows named pipe in a net rule?

Problem: a Windows pipe (\\.\pipe\app) isn't a unix socket, but Node handles both through the same PipeWrap (libuv uv_pipe_t).

Options:

  • A. unix:\\.\pipe\app, as in Deno: one prefix, but a misleading name.
  • B. pipe:\\.\pipe\app: a clear name, but two prefixes.

Recommendation: A.

Q8: IDN

Importance: Low

Question: should bücher.example work as a rule?

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.

Options:

  • A. Convert: bücher.example becomes xn--bcher-kva.example before matching. Friendly, but look-alike names get through.
  • B. Reject: a non-ASCII rule is an error, and users write the xn-- form. Simple and safe.
Q9: Counter width

Importance: Low

Question: should the generation counter be uint32 or uint64?

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.

Future work

  • --deny-net, with deny checks after resolution (catches a name that resolves to a denied IP).
  • Proxy awareness: check the target host behind HTTP_PROXY, not just the proxy.
  • Faster matchers, if benchmarks show a need.
Lingua principale
JavaScript
Stelle
123k
Fork
39.3k
Merge medio
4g 16h
PR unite (30g)
231

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di nodejs/node

Tutte le issue di nodejs/node

Issue simili

Altre issue su JavaScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.