Skip to content

Refuse allow-listed hostnames that resolve to loopback, link-local or configured private ranges - #522

Open
dannycochran wants to merge 22 commits into
mainfrom
dcochran/resolved-ip-guard
Open

Refuse allow-listed hostnames that resolve to loopback, link-local or configured private ranges#522
dannycochran wants to merge 22 commits into
mainfrom
dcochran/resolved-ip-guard

Conversation

@dannycochran

@dannycochran dannycochran commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

allowedDomains / deniedDomains decide by name. Whoever controls a permitted name's DNS records — or any label under a permitted wildcard — decides what that name resolves to, and nothing between the allowlist decision and net.connect() looked at the resolved address. So with

{ "network": { "allowedDomains": ["*.example.com"], "deniedDomains": [] } }

a sandboxed process could request internal.example.com, have it resolve to 127.0.0.1 (or 169.254.169.254, this machine's LAN address, or an address in a range the embedder considers private), and the proxy would dial it — reaching services on the host that the allowlist never meant to expose. This is the classic DNS-rebinding / allowlist-bypass shape; it applied to the opaque CONNECT tunnel, the plain-HTTP forward, the SOCKS path and the TLS-terminated upstream leg alike. (See also #65.)

What changed

src/sandbox/resolved-address-guard.ts (new). A small guard built on net.BlockList that exposes a lookupFor(port) function for net.connect. It takes the network config's own allowedDomains / deniedDomains / deniedResolvedAddresses and derives the IP-literal rules itself, so the manager passes the config straight through. It resolves the hostname once (dns.lookup, all: true), drops addresses in the denied set, and hands the survivors to the runtime — so the address that passed the check is the address dialed (no check-then-resolve-again window), and the runtime's normal multi-address / dual-stack fallback is untouched. If nothing survives it fails with a typed ResolvedAddressDeniedError. Generic IP/CIDR helpers (parseAddressRange, addRange, addressInSet, loopback predicates) live in a new leaf src/sandbox/address.ts, which the parent-proxy NO_PROXY parser and config validation now share.

Denied set (hostnames only):

  • built in: loopback (127.0.0.0/8, ::1), unspecified (0.0.0.0/8, ::), link-local (169.254.0.0/16, fe80::/10), multicast (224.0.0.0/4, ff00::/8), broadcast (255.255.255.255), and the cloud metadata / platform endpoints that live outside link-local — 100.100.100.200 (Alibaba), 168.63.129.16 (Azure WireServer), 192.0.0.192 (Oracle Classic), fd00:ec2::/32 (the AWS IPv6 service block: IMDS ::254, EKS Pod Identity ::23), fd20:ce::254 (Google Cloud IPv6-only VMs), fd00:c1::a9fe:a9fe (OCI), fd00:42::42 (Scaleway), fd00:a9fe:a9fe::1 (Akamai/Linode), fd00:100::100:200 (Alibaba IPv6));
  • every address currently assigned to one of this host's network interfaces (os.networkInterfaces(), read per lookup) — loopback is only the most common spelling of "this host"; a service bound to 0.0.0.0/:: answers on the LAN, VPN or global-IPv6 address just the same, and no private-range list covers a global address;
  • every IP literal already listed in deniedDomains (honouring its :port), so an address the user said "never" to is denied however it is reached;
  • network.deniedResolvedAddresses — new key: extra IPs / CIDRs (IPv4 or IPv6, unbracketed, any port), e.g. ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "fc00::/7"].

One spelling per address. IPv4 rules also bind the IPv6 forms that carry an IPv4 address. An IPv4-mapped answer (::ffff:127.0.0.1, ::ffff:7f00:1, uppercase/expanded forms) is matched by BlockList itself; embeddedIPv4() in address.ts extracts the address from the other forms a network delivers to that IPv4 destination — IPv4-compatible (::7f00:1), IPv4-translated (::ffff:0:a.b.c.d), the NAT64 well-known prefix (64:ff9b::/96), and 6to4 (2002::/16) — and the deny rules are applied to that spelling as well. The carried IPv4 only ever adds denials; it never earns an allow-list carve-out (so ::1, whose IPv4-compatible form is 0.0.0.1, cannot ride a carve-out for 0.0.0.1). The local-use prefix 64:ff9b:1::/48 and network-specific prefixes are not decoded — their layout can't be told from the address; on such a network, list the prefix's translations of the ranges you deny. That closes re-encodings such as 64:ff9b::7f00:1, 64:ff9b::a9fe:a9fe or 64:ff9b::<this host's LAN address> (reachable wherever a NAT64 gateway, or a 6to4 / sit interface, honours them) without denying those prefixes wholesale, which would break NAT64 to public IPv4 destinations on IPv6-only networks. A network-specific NAT64 prefix can't be recognised from the address alone; the README says to list its /96 twins of the denied ranges.

Rules are stored in one form too: an IPv4-mapped entry becomes the IPv4 rule it stands for (::ffff:a00:0/10410.0.0.0/8) and a zone id is dropped. Bun 1.3.x evaluates a subnet rule whose network is IPv4-mapped with the wrong prefix (oven-sh/bun#33296: ::ffff:127.0.0.1/128 matches every IPv4 address, so a single allow-listed [::ffff:127.0.0.1] would have switched the check off for all of IPv4) and refuses a zoned rule; addRange also reports a rule the runtime refuses as not-added rather than throwing, which keeps the NO_PROXY parser as tolerant as it was. And canonicalizeHost now spells an IPv4-mapped destination as its dotted quad, so a request for [::ffff:127.0.0.1] is matched against the allow/deny lists (and dialed) as 127.0.0.1 — previously the mapped spelling of a deny-listed address got past the deny list whenever the allowlist or an ask callback admitted that spelling. A zone id on a resolved address is stripped before matching.

Deliberate carve-outs — and no carve-out key:

  • IP literals are never re-judged. Allow-listing 127.0.0.1:3000 or [::1] is an explicit choice; the guard only applies to names.
  • A name may resolve to an otherwise-denied address only when that IP literal (on that port) is itself in allowedDomains — reaching it by name grants nothing the literal entry did not. A literal in deniedDomains still wins, in the order the name filter uses (deny-list literal, allow-list literal, the localhost rule, then the denied set): allowedDomains: ["127.0.0.1", "myapp.test"] with deniedDomains: ["127.0.0.1:6379"] refuses myapp.test:6379 exactly as it refuses the literal. The dev setup where myapp.test maps to a local server via /etc/hosts is ["myapp.test", "127.0.0.1:3000"]. There is deliberately no separate carve-out list: a global, port-less "names may resolve to 127.0.0.1" switch would re-open every loopback port to every allow-listed name, whereas the literal entry scopes it to what the sandbox could already reach directly.
  • localhost and names under .localhost (RFC 6761) resolve to loopback (or an allow-listed literal) — that is what allow-listing them asks for — and to nothing else.
  • RFC 1918 / ULA / CGNAT are not denied by default. Allow-listing an intranet hostname is a legitimate configuration, so private-use space is opt-in via deniedResolvedAddresses rather than a default that would break those setups.

The new key is validated by the zod schema (malformed entry → config error) and documented in the README next to the other network keys. updateConfig() rebuilds the guard and the proxies read it live, so changes apply to the next connection.

Enforcement — the manager hands both proxies one lookupFor(port, encodedCommand) factory that wraps the guard and records a refusal in the violation store itself (deny network-outbound host:port (resolved to a loopback address), with the usual per-command attribution). Every direct dial uses it:

  • dialDirect() (opaque CONNECT tunnel and SOCKS) → net.connect({ host, port, lookup })
  • plain-HTTP forward and TLS-terminated upstream leg → directRequestOptions(): the name is dialed exactly as a tunnel would be (dialDirect — guard lookup, connect timeout, the runtime's address-family fallback), the address that answered is kept and that vetting connection released, and http(s).request is then made to that address with the name carried in Host and, for TLS, servername (SNI and certificate verification stay on the hostname). So the vetted address is still the one used, with no second resolution — and lookup is deliberately not handed to the HTTP client: Bun's node:http client up to 1.3.x (CI pins 1.3.14) resolves through a custom lookup but then drops or repeats a streamed request body, and ignores createConnection (Http.request createConnection is not called oven-sh/bun#7471), so the vetted socket cannot simply be adopted. Bun 1.4 replaced that client (node:http: rewrite the client on net/tls + llhttp (Node http suite: ~55% → 82.3%, +182 vendored tests, client proxy support) oven-sh/bun#31587) and honours both; once that is the floor the vetting connection can become the request's own socket and the second connect goes away — the helper's doc says so. Both legs use agent: false — the global agent is shared with the embedding process (and Bun caches the first request's ca on it), and the vetting dial runs per request anyway, so a pool would buy nothing but the second connect. The terminated leg now sets Host from the tunnel's target (the name the allowlist saw) rather than deleting it and letting the runtime derive it; formatAuthority() is the one spelling of name[:port] (IPv6 bracketed, default port elided) for that Host, the plain leg's Host, and the terminated leg's filterRequest URL (which previously left an IPv6-literal target unbracketed).

The dial sites keep only their protocol's answer: HTTP and CONNECT get the proxy's standard policy denial (403, X-Proxy-Error: blocked-by-sandbox-runtime, the reason — Connection to <host> blocked: resolved to a loopback address — as the body), a CONNECT whose 200 already went out on the TLS-sniff path is closed, SOCKS gets reply 0x02 (connection not allowed by ruleset) instead of "host unreachable". The 403 writers (rawDenied, respondDenied, respondUpstreamError) live in request-filter.ts and are shared with the existing allowlist / filterRequest denials. The reason names the class of address — loopback, unspecified, link-local, multicast, broadcast, cloud metadata, one of this host's addresses, a deny-listed address, a listed address, or a non-loopback address for a localhost name — never the address: the requester is the party the check defends against and usually has no resolver of its own, so echoing the answer would make every refusal an oracle for what an allow-listed name maps to inside denied space (this host's LAN address, an intranet address). The addresses go to the debug log and stay on the error object. dialDirect(host, port, lookup) takes lookup as a required argument, so a future direct dial that forgets it is a type error rather than an unguarded connect.

Intentionally out of scope: connections routed through parentProxy or the mitmProxy socket are not resolved locally at all — that hop resolves the name and owns the equivalent policy (documented in the README and on the option). The check governs what the proxy dials; on macOS allowLocalBinding separately admits direct loopback connects from the sandboxed process, and addresses that reach this host without being assigned to it (1:1-NAT public address, port-forwards, container host-gateway aliases) are the embedder's to list — the README says both next to this feature. shouldBypassParentProxy's "always bypass loopback" behaviour is unchanged; its loopback set and NO_PROXY CIDR parsing now come from address.ts.

Tests

test/sandbox/resolved-address-guard.test.ts (43 tests):

  • range parsing / schema validation (valid v4/v6 literals and CIDRs; hostnames, bad prefixes, bracketed IPv6 and mapped ranges wider than /96 rejected with a pointed message; IPv4-mapped entries stored as the IPv4 rule; zone ids dropped); embeddedIPv4 extraction table (mapped, compatible, translated, NAT64 well-known and local-use /96, 6to4, and the look-alikes that are not);
  • permits table: built-in set incl. v4-mapped, expanded/uppercase and zoned (fe80::1%en0, ::1%lo0) forms denied; the host's own interface addresses denied (injected list, read live; plus one test against the machine's real interfaces); the cloud metadata set denied; NAT64 / 6to4 / IPv4-compatible / IPv4-translated answers that embed a denied IPv4 (loopback, metadata, this host's LAN address, an embedder CIDR) denied while the same forms carrying a public IPv4 pass; an IPv4-mapped literal in either list binds exactly its IPv4 address (the Bun #33296 case, under CI's Bun); a deny-listed literal wins over an allow-listed one and over the localhost rule; public and private-use addresses permitted by default; IP-literal destinations never re-judged; localhost names ↔ loopback only; embedder-denied ranges; port-scoped allow and deny rules; rules derived from the IP-literal entries of allowedDomains/deniedDomains (ipLiteralRules); malformed entry throws;
  • lookupFor: all-denied → typed error whose reason names the class of the refused addresses, never the addresses; mixed → survivors only (both callback forms); literal destinations never filtered; empty answer → ENOTFOUND; resolver errors propagate;
  • through the real servers with an injected resolver that answers on a later tick like dns.lookup (no network): plain HTTP, CONNECT and SOCKS each refuse an allow-listed name that resolves to loopback (CONNECT and SOCKS also link-local) and record it; an allow-listed IP literal and localhost still connect; a permitted resolution is dialed at the resolved address with the Host header preserved; a streamed, chunked-reframed POST reaches the upstream intact and exactly once; an embedder-configured range is refused;
  • TLS-terminated leg (curl through the proxy): denied resolution → 403 inside the terminated session with the upstream untouched; non-TLS bytes after the sniff path's 200 → tunnel closed and violation recorded; permitted resolution → dialed at the resolved address with the certificate still verified against the hostname; a POST body reaches the upstream intact and exactly once. (Both body tests fail under Bun if lookup is handed to http(s).request instead.)

The end-to-end credential-masking suites (credential-mask, credential-mask-env, credential-mask-files, Linux) used localtest.me — a public wildcard name resolving to 127.0.0.1 — as their second allow-listed host, which is precisely what the check refuses; they now use names under .localhost, which also removes their dependency on public DNS (the curl --resolve hints went too: curl hands the absolute URI to the proxy, so the proxy always resolved that name). Those names are resolved by the host's own resolver — glibc/systemd synthesize .localhost per RFC 6761 — and these suites run on Linux only.

canonical-host-routing gains a case where the IPv4-mapped spelling of a deny-listed address is itself allow-listed and still refused; parent-proxy gains canonicalisation cases for mapped literals and NO_PROXY cases for zoned and mapped entries (a zoned entry threw under Bun once the parser shared addRange; a mapped one bypassed the parent proxy for every IPv4 host). The Windows CI job runs only the winsrt and mux-proxy suites, so these proxy suites run on the Linux and macOS jobs. Existing suites (parent-proxy, parent-proxy-tunnel, tls-terminate-proxy, client-abort, connect-non-tls, socks-unauthenticated-probe, proxy-deny-violations, canonical-host-routing, http-proxy-verdict-liveness, request-filter, body-substitution, domain-pattern, mux-proxy*, config-validation) pass unchanged under Bun 1.3.14 (the credential-mask suites with the .localhost rename described above). The four dial paths were exercised under Node 24 against the built dist/ at an earlier revision; the dial-then-request shape was separately verified under Node 24, Bun 1.3.14 and Bun 1.3.5 (peer address taken from the vetting dial incl. ::1, no DNS for the literal, Host/SNI on the name, certificate checked against the name and refused on a mismatch, no redirect following). eslint, tsc --noEmit, npm run build and prettier are clean.

Manual verification (macOS arm64; built dist/ at 2b8c021 — and earlier at 0bc5c94 / 73a5805 with identical results — vs main at 66d35e5; Node 24.18 and Bun 1.3.14). The proxy stack was started in-process via SandboxManager.initialize() and driven with curl -x http://srt:<token>@127.0.0.1:<muxPort> / -x socks5h://… — the OS sandbox layer only forces a child through this proxy and was not part of the run. Host listeners: a marker page on 127.0.0.1:18080 and 0.0.0.0:18081, an echo server (body length + sha256) on 127.0.0.1:18082. The test host had no external DNS, so the wildcard names (127-0-0-1.nip.io, 169-254-169-254.nip.io, 0-0-0-0.nip.io, <lan-ip>.nip.io) were answered by an in-process dns.lookup table with the addresses the public service returns; one cell used the real system resolver via a hosts-file name.

  • allowedDomains: ["*.nip.io", "localhost"]GET http://127-0-0-1.nip.io:18080/403, X-Proxy-Error: blocked-by-sandbox-runtime, body Connection to 127-0-0-1.nip.io blocked: resolved to denied address 127.0.0.1, violation deny network-outbound 127-0-0-1.nip.io:18080 (resolved to denied address 127.0.0.1); CONNECT → 403; SOCKS5 → reply 2. Same for the names resolving to 169.254.169.254, 0.0.0.0 and the machine's own LAN address (listener on 0.0.0.0 never reached). On main each of these returns 200 with the host service's content over plain HTTP, CONNECT and SOCKS, and the link-local name is dialed.
  • localhost:18080 → 200 on all three legs; the literal 127.0.0.1:18080 → the usual allow-list 403 unless listed.
  • Adding "127.0.0.1:18080": 127-0-0-1.nip.io:18080 → 200 and 127-0-0-1.nip.io:18082 → 403 (port-scoped); adding bare "127.0.0.1" permits the name on both ports, as the literal already does.
  • deniedResolvedAddresses: ["198.51.100.0/24"] with a hosts-file name resolving to 198.51.100.0 (real resolver) → 403 resolved to denied address 198.51.100.0; without the entry the proxy dials it.
  • POST through the plain-HTTP leg to localhost:18082 — 7-byte body in the header write, 7-byte chunked, 1 MiB Content-Length, 1 MiB chunked — arrives intact (sha256 match) exactly once under Node and Bun 1.3.14. The same run against 8cb623b^ under Bun 1.3.14 delivers the 7-byte body empty and the small chunked POST never reaches the upstream.
  • Not covered by this run: the OS-sandboxed child path and the TLS-terminated leg (unit tests above); a Linux (bwrap) end-to-end run is reported separately.

That run predates the review fixes described above. They leave its refusals and permits as they were, but its bodies and violation lines named the address (resolved to denied address 127.0.0.1) where they now name the class (resolved to a loopback address). The IPv6-embedding cases were driven the same way (in-process proxy, injected resolver) under Bun 1.3.14 at the fixed head: CONNECT to names answering 64:ff9b::7f00:1, 64:ff9b::a9fe:a9fe, 2002:7f00:1::1 and ::7f00:1 → 403 with one violation each (the pre-fix head dialed all four); a name answering 64:ff9b::c000:20a (a public IPv4) is dialed.

Compatibility

Behaviour changes only for allow-listed hostnames that resolve into the denied set (loopback / unspecified / link-local / multicast / broadcast / this host's own interface addresses / IP literals in deniedDomains): those connections are now refused where they previously went through. Setups that point named dev hosts at a local server keep working by allow-listing the IP literal alongside the name ("127.0.0.1:3000"), or localhost. Nothing changes for IP-literal allowlist entries, for localhost, or for traffic that leaves via parentProxy / mitmProxy. Plain-HTTP requests that the proxy forwards directly no longer reuse an upstream keep-alive connection across requests, and both the plain-HTTP forward and the TLS-terminated leg open one extra short-lived TCP connection per request to a hostname upstream (the vetting dial, released as soon as it connects) — the upstream sees a connection that closes without sending anything, so a listener that accepts exactly one connection (e.g. nc -l) or a per-source connection counter will notice it; opaque CONNECT tunnels and SOCKS are unaffected. A resolution refusal or connect failure on those two legs is now answered before the upstream request is created (same 403 / 502). The plain-HTTP allowlist denial body now ends with a newline, like the other policy denials. A resolved-address refusal names the class of address, not the address. An allow- or deny-list entry spelled as an IPv4-mapped literal ([::ffff:10.0.0.1]) now means that IPv4 address, and a request to such a literal is matched and dialed as the IPv4 address (the same destination). A zone id on an IPv6-literal entry ([fe80::1%en0]) is dropped, so the entry matches that address on any interface. An IPv6 range in deniedResolvedAddresses broad enough to cover the IPv4-mapped block (e.g. ::/0) matches IPv4 answers on some runtimes but not others — list IPv4 and IPv6 ranges separately. Each directly-forwarded plain-HTTP request also costs a name lookup, since there is no connection reuse on that leg any more.

Runtime note: the TLS-terminated leg now sets Host itself and relies on servername for SNI and certificate verification (whether or not a resolved-address lookup is wired). The upstream certificate is verified with an explicit checkServerIdentity pinned to the tunnel's target host, so the identity checked is the name the allowlist saw regardless of how a runtime maps the Host header or SNI to it. Node and Bun ≥ 1.3.11 (CI pins 1.3.14) honour that; Bun ≤ 1.3.x ships a defective node:http/node:https client (the same defect the direct legs re-dial around, oven-sh/bun#7471) that verifies against the Host header verbatim and ignores checkServerIdentity, so a TLS-terminated upstream on a non-default port fails closed there with a certificate-name error. Bun 1.4 (oven-sh/bun#31587) rewrote that client and is the clean floor for both.

Refs #65

…ddresses

The domain allowlist decides by name, but whoever controls a permitted
name's DNS records (or any label under a permitted wildcard) decides what
it resolves to. Nothing between the allowlist decision and the dial looked
at the resolved address, so a permitted name could be pointed at the
host's loopback or link-local interfaces (or, for embedders that care,
private address space) and the proxy would connect to it.

Add a resolved-address guard: a net.BlockList-backed `lookup` that
resolves a hostname once, drops addresses in a denied set (loopback,
unspecified, link-local, multicast, broadcast by default, plus
network.deniedResolvedAddresses; network.allowedResolvedAddresses carves
out exceptions) and hands the survivors to the runtime, so the address
that passed the check is the one dialed. The manager wires it into every
direct dial - the opaque CONNECT tunnel and SOCKS (dialDirect), the
plain-HTTP forward and the TLS-terminated upstream leg. A refusal is
reported like an allowlist denial (403 / SOCKS "not allowed" plus a
violation-store line). IP-literal allowlist entries and the reserved
localhost names are left alone, and parentProxy/mitmProxy routes are not
resolved locally at all.
…, and non-loopback answers for localhost names

Loopback is only the most common spelling of "this host": a service bound
to 0.0.0.0 answers on the machine's LAN, VPN or global IPv6 address just
the same, and no private-range list covers a global address. The guard now
also refuses any address currently assigned to a local interface (read per
lookup, so interface changes are picked up; carve-outs still win).

A link-local result carrying a zone id (fe80::1%en0) passed the check on
runtimes whose BlockList treats a zoned address as a non-match; addresses
are now compared in one canonical form (zone dropped, IPv6 compressed and
lower-cased, v4-mapped unmapped).

`localhost` / `*.localhost` names may resolve to loopback and nothing else
(RFC 6761), rather than "loopback plus anything not otherwise denied".

Adds the two instance-metadata endpoints that live outside link-local
(100.100.100.200, fd00:ec2::254) to the built-in set, tolerates interface
enumeration being unavailable, and documents that on macOS
allowLocalBinding grants direct loopback reach the proxy never sees.
With the default global agent (keep-alive on since Node 19) a pooled
socket for host:port is handed back without consulting `lookup`, so a
connection established under an earlier, laxer resolved-address policy -
or by the embedding process's own use of the global agent - kept serving
sandboxed requests after the policy changed. The direct leg now opens its
own connection per request, matching the TLS-terminated leg, so every
request goes through the resolved-address check.
…eny lists; drop allowedResolvedAddresses

The allow/deny lists already say which addresses are off-limits and which
are explicitly fine, so the resolved-address check now reads them instead
of keeping a second rulebook:

- an IP literal in `deniedDomains` (with its `:port`, if any) is denied
  however it is reached - by literal or by a name that resolves to it;
- a name may resolve to an otherwise-denied address only when that IP
  literal (on that port) is itself in `allowedDomains`, so reaching it by
  name grants nothing the literal entry did not. `allowedResolvedAddresses`
  is removed: a global, port-less carve-out (`["127.0.0.1"]` was the
  documented recipe) re-opened every loopback port to every allow-listed
  name. The dev setup becomes `["myapp.test", "127.0.0.1:3000"]`.
  `deniedResolvedAddresses` stays for CIDR ranges.

The guard is now built per port (`lookupFor(port)`), and the manager hands
the proxies one `lookupFor(port, encodedCommand)` that records the
violation itself; the four dial sites keep only their protocol's answer
(403 / SOCKS not-allowed / destroy-after-200) via shared
`rawDenied` / `respondDenied` / `respondUpstreamError` writers, with the
error's message as the body and the standard `blocked-by-sandbox-runtime`
tag. Generic IP/CIDR helpers move to `address.ts`; local interface
addresses are matched through a BlockList like everything else (which
already handles IPv4-mapped, uppercase and uncompressed spellings), so
only the zone id needs stripping. `dialDirect` takes the lookup directly,
NO_PROXY parsing reuses `addRange`, and the Bun<=1.3.10 `Host`-header
accommodation on the TLS-terminated leg is dropped (that leg needs
Bun >= 1.3.11 when run under Bun; Node is unaffected).

Tests: port-scoped allow/deny rules, rules derived from list entries,
zoned loopback, empty resolver answer, and the CONNECT sniff path where a
denied dial arrives after the 200.
…kup` to the HTTP client

The plain-HTTP forward and the TLS-terminated upstream leg passed the
resolved-address guard's `lookup` to `http(s).request`. Under Bun that
client resolves through `lookup` but then mishandles a streamed request
body - one that finishes before the lookup answers is dropped (or sent
with both Content-Length and Transfer-Encoding), and the request can be
issued more than once - so POSTs through either leg arrived empty or
malformed (a `git ls-remote` through the terminating proxy failed with
"expected flush after ref listing"; a chunked-reframed POST got a 400).
Bun's client also cannot adopt an already-open socket, so the fix cannot
be `createConnection`.

Both legs now pick their upstream the way a tunnel does: `dialDirect`
(guard lookup, connect timeout, the runtime's address-family fallback)
connects, the address that answered is kept and the probe released, and
the request is made to that literal with the name carried in Host and,
for TLS, `servername` - so the vetted address is still the one used, with
no second resolution, and `lookup` never reaches the HTTP client. The
terminated leg therefore sets Host from the tunnel target (the name the
allowlist saw) instead of deleting it. A refusal or connect failure now
surfaces before the request object exists and is answered the same way
(403 with the reason / 502).

Tests: a streamed, re-framed POST through the plain leg and a POST through
the terminated leg reach the upstream intact and exactly once (both failed
under Bun before); the resolver stub answers on a later tick like
dns.lookup; the raw-socket helper completes on Content-Length rather than
waiting for a server-side close.
The end-to-end masking suites need a second allow-listed hostname that
reaches a local upstream, and used `localtest.me` (a public wildcard that
resolves to 127.0.0.1). The resolved-address check refuses exactly that -
an allow-listed public name pointing at loopback - so those requests now
got a 403 and never reached the test server. Use names under `.localhost`
instead (RFC 6761: they resolve to loopback locally, and the check lets
them), which also drops the suites' dependency on public DNS. The
`--resolve` hints are removed: curl hands the absolute URI to the proxy,
so it was always the proxy that resolved the name.
…ts upstream

The plain-HTTP forward and the TLS-terminated leg each spelled out the
same thing after the vetting dial: request the literal, carry the name
in `servername` unless it is an IP literal, no shared agent - with two
diverging comments. `directRequestOptions(host, port, lookup, tls)`
now owns the probe and returns those connection options, so the legs
only add what is theirs (path/method/headers; the terminated leg its
`ca`). Its doc records why the second connect exists and the condition
for removing it (Bun's rewritten node:http client honours
`createConnection`).

The `name[:port]` authority - IPv6 bracketed, default port elided - was
also built three ways (plain leg Host, terminated leg Host, terminated
leg filterRequest URL, the last without brackets, so an IPv6-literal
tunnel target produced a malformed filter URL). `formatAuthority()` is
the one spelling; the terminated leg computes it once for the filter
URL, Host and SigV4. `DirectLookup` moves next to `dialDirect` so the
SOCKS server no longer imports a type from the HTTP server.
… test comment

The vetting dial's catch block and the request's 'error' listener logged
and answered identically in each leg; each leg now has one
`failUpstream`. The terminated leg's post-dial abort check matches the
plain leg's (`res.destroyed || req.socket.destroyed`). The TLS test
header comment described the old shape (https.request receiving the
lookup) and is rewritten for dial-then-request.
… entries as IPv4, zone ids dropped

Every rule (the built-in set, `deniedResolvedAddresses`, the IP literals
of `allowedDomains`/`deniedDomains`, NO_PROXY entries) went into a
`BlockList` in whatever family it was spelled in. Two runtime differences
made that unsafe under Bun 1.3.x: a subnet rule whose network address is
IPv4-mapped is evaluated as an IPv4 rule with the wrong prefix
(oven-sh/bun#33296 — `::ffff:127.0.0.1/128` matches every IPv4 address,
and the `/104` form panics on Windows), so one allow-listed
`[::ffff:127.0.0.1]:3000` turned the resolved-address check off for all
of IPv4 on that port and a mapped deny entry refused everything; and a
zoned rule (`fe80::1%eth0`) throws where Node accepts it, so a
schema-valid config entry or an inherited NO_PROXY value aborted startup
(main's NO_PROXY parser swallowed that).

`parseAddressRange` now yields the one form matching already treats as
equivalent: a mapped entry becomes the IPv4 rule it stands for (prefix
minus 96; a mapped range wider than /96 is rejected) and a zone id is
dropped. `addRange` reports a rule the runtime refuses as not-added
instead of throwing. Tests pin the parse table, the allow/deny-literal
case under the CI runtime, and the NO_PROXY spellings.
…4 address they carry

`BlockList` matches an IPv4 rule against the IPv4-mapped IPv6 form but not
against the other forms that carry an IPv4 address, so an allow-listed
name answering with `64:ff9b::7f00:1`, `64:ff9b::a9fe:a9fe`,
`2002:7f00:1::1` or `::7f00:1` was dialed. Where the network honours the
form (a NAT64 gateway on an IPv6-only subnet, a 6to4 or `sit` tunnel
interface) that reaches loopback, the link-local metadata endpoint, this
host's LAN address or an embedder-denied range under another spelling.
`embeddedIPv4()` extracts the address from the NAT64 well-known prefixes,
6to4, IPv4-compatible and IPv4-translated forms, and the guard applies
the allow and deny rules to that spelling as well — rather than denying
those prefixes wholesale, which would break NAT64 to public IPv4
destinations on IPv6-only networks.
…of any allow-list carve-out

The guard checked the allow list's literal carve-outs (and the localhost
rule) before a merged denied set into which the manager had folded the
deny list's IP literals, while the name filter checks `deniedDomains`
first. So with `allowedDomains: ["127.0.0.1", "myapp.test"]` and
`deniedDomains: ["127.0.0.1:6379"]` the literal request was refused but
`myapp.test:6379` connected — the README's "denied however it is reached"
did not hold whenever the two lists overlapped.

The guard now takes the three lists themselves (`allowedDomains`,
`deniedDomains`, `deniedResolvedAddresses`), derives the IP literals in
one place, and applies them with the filter's precedence: deny-list
literal, then allow-list literal, then the localhost rule, then the
built-in set / `deniedResolvedAddresses` / this host's addresses. The
manager passes the network config straight through, so the tests exercise
the production derivation rather than a re-assembled copy. README: the
deny list wins; a localhost name may also resolve to an allow-listed
literal; a parent proxy inherited from the environment counts as
`parentProxy`.
…outside link-local

The built-in set motivated `169.254.0.0/16` with instance metadata and
listed two endpoints outside it; several providers serve credentials or
host configuration from addresses that neither link-local nor the
private-range opt-in covers — the Azure WireServer (`168.63.129.16`,
publicly routable, reachable from every Azure VM), Oracle Classic
(`192.0.0.192`), and the IPv6 metadata addresses of AWS (the whole
`fd00:ec2::/32` service block: IMDS `::254`, EKS Pod Identity `::23`),
Google Cloud IPv6-only instances (`fd20:ce::254`), OCI
(`fd00:c1::a9fe:a9fe`) and Scaleway (`fd00:42::42`). All single addresses
or a provider-reserved block, so nothing legitimate is caught; grouped as
`CLOUD_METADATA_ADDRESSES` with the README list to match.
…not the address

The 403 body and the violation line (which embedders surface to the
sandboxed agent) carried the refused addresses verbatim. The requester is
the party the check defends against and usually has no resolver of its
own, so that made every refusal an oracle for what an allow-listed name
maps to inside denied space — this host's LAN address, an intranet
address in a listed range. The reason now names the class instead
(`resolved to a loopback address`, `… a link-local address`, `… one of
this host's addresses`, `… a cloud metadata address`, `… a deny-listed
address`, `… a listed address`), which is also the more useful hint for
whoever has to adjust the allowlist; the addresses themselves go to the
debug log and stay on the error object for operator tooling. The built-in
set is grouped by that class; `DEFAULT_DENIED_RESOLVED_ADDRESSES` is
derived from it.
Every direct destination dial must go through the resolved-address guard's
lookup; with the parameter optional, a future caller that forgot it would
compile and dial unguarded. Making it a required (possibly undefined)
argument turns that omission into a type error. No caller changes.
… before any list check

`canonicalizeHost` left `[::ffff:127.0.0.1]` as `::ffff:7f00:1` (the WHATWG
serialisation), while the allow/deny lists and the dial treat hosts as
strings. A connect to that literal goes to 127.0.0.1 on every dual-stack
stack, but a `deniedDomains` entry for `127.0.0.1` never compared equal to
it — so the IPv4-mapped spelling of a deny-listed address got past the deny
list whenever the allowlist (or an ask callback) admitted that spelling.
The resolved-address check already treats the two spellings as one
address; this makes the name filter agree.

The canonical form of an IPv4-mapped literal is now the dotted quad, so
requests, allow/deny entries (which go through the same function) and the
dial all use one spelling. Other IPv4-embedding forms (NAT64, IPv4-
translated) are different destinations and stay IPv6.
…t; doc precision

`embeddedIPv4()` took the low 32 bits of any `64:ff9b:1::/48` address. That
is right when the operator uses a /96 inside the local-use prefix, but in
the shorter RFC 6052 layouts the IPv4 address sits in the middle and the
low bits are zero, so every such translation read as 0.0.0.0 and was
refused. An address there whose low 32 bits are zero is now left alone;
the README says a network-specific prefix has to be listed by the embedder
(its /96 twins of the denied ranges).

Doc precision from review: a localhost name may also resolve to an
allow-listed literal (module docstring), and the config description says a
parent proxy taken from HTTP_PROXY/HTTPS_PROXY counts as parentProxy.
…llow-list carve-out

`embeddedIPv4()` decodes the IPv4 an IPv6 answer carries (NAT64, 6to4,
IPv4-compatible), and the guard judged both spellings against the allow
list as well as the deny list. That let a carried form inherit an
allow-list carve-out: `::1` (loopback) carries `0.0.0.1` in the
IPv4-compatible layout, so a carve-out for `0.0.0.1:3000` admitted a name
resolving to `::1` on that port — a resolved loopback address reached
through an unrelated carve-out.

The carried form is now used only to ADD denials; the allow-list carve-out
and the localhost rule key on the address as resolved. A genuine
IPv4-mapped carve-out is unaffected — `net.BlockList` matches
`::ffff:127.0.0.1` against a `127.0.0.1` rule in either spelling, so no
decode is needed for it. A name resolving to a carried form of an
allow-listed-but-also-denied address is now refused, matching the rule
that reaching an address by name grants nothing its own literal entry
does not.
…l-use /48

`embeddedIPv4` treated the low 32 bits of a `64:ff9b:1::/48` address as the
carried IPv4. RFC 6052 §2.2 lets a prefix longer than /32 put the IPv4 in
several positions (/32…/96), and the local-use prefix can use any of them,
so the low-bits guess is wrong for every layout except /96 — decoding
`64:ff9b:1:0:5d:b8d8:7f00:0` as a loopback address, for one. That both
falsely refuses real destinations and could read the wrong IPv4 (missing a
denied one). Only the well-known prefix `64:ff9b::/96`, whose layout is
fixed, is decoded now; a network using a local-use or network-specific
prefix lists that prefix's translations of the ranges it denies, as the
README says. Also short-circuits `embeddedIPv4` for a non-IPv6 input before
the URL parse.
…arget

The terminated leg sets the upstream `Host` header from the tunnel's
target (name and, for a non-default port, `:port`). It relied on the
runtime verifying the upstream certificate against `servername`; a runtime
that verifies against the `Host` header instead (some older clients do)
sees `name:port`, which never matches a SAN, and fails the connection.
Pin the check with an explicit `checkServerIdentity` against the tunnel's
target host, so the identity the cert is verified against is the name the
allowlist saw regardless of how a runtime maps Host/SNI to it. Behaviour
is unchanged on runtimes that already verify against `servername`; a
mismatched certificate is still refused.
…IPv6 metadata endpoints

A zone id on an IP-literal `allowedDomains`/`deniedDomains` entry
(`[fe80::1%en0]`) was kept by the name filter (which never matches it,
since a request cannot carry a zone) but dropped by the resolved-address
guard, so a literal request and a name resolving to the same address got
opposite verdicts, and a zoned allow entry silently applied to the address
on every interface. Drop the zone where the entry is parsed, so both sides
read it as the same unzoned address — matching how the guard already
stores it.

Also add the IPv6 metadata endpoints for Akamai/Linode (`fd00:a9fe:a9fe::1`)
and Alibaba Cloud (`fd00:100::100:200`) to the cloud-metadata set.
…cleanups

- The refusal reason joined its address classes in whatever order the
  resolver returned the answers, so `ignoreViolations` (a substring match)
  could match one ordering and miss the other. Order the classes by the
  built-in priority instead, and build the reason once.
- Un-export the address-module internals nothing else uses (`ipFamily`,
  `AddressFamily`, `AddressRange`, `buildAddressSet`) and the guard's
  `CLOUD_METADATA_ADDRESSES`; delete the now-unused
  `DEFAULT_DENIED_RESOLVED_ADDRESSES`.
- `mappedIPv4` skips the URL parse for a string with no `ffff`.
- Compute `isHttps`/the default port once in the plain-HTTP handler, drop a
  stale comment, and give the allowlist denial one message constant.
- Fix a test importing `DirectLookup` from the wrong module, wrap an
  over-long docstring line, and note in the README that an IPv6 range
  covering the mapped block does not reliably deny IPv4 across runtimes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants