Refuse allow-listed hostnames that resolve to loopback, link-local or configured private ranges - #522
Open
dannycochran wants to merge 22 commits into
Open
Refuse allow-listed hostnames that resolve to loopback, link-local or configured private ranges#522dannycochran wants to merge 22 commits into
dannycochran wants to merge 22 commits into
Conversation
…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.
zjw4242
approved these changes
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
allowedDomains/deniedDomainsdecide 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 andnet.connect()looked at the resolved address. So with{ "network": { "allowedDomains": ["*.example.com"], "deniedDomains": [] } }a sandboxed process could request
internal.example.com, have it resolve to127.0.0.1(or169.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 onnet.BlockListthat exposes alookupFor(port)function fornet.connect. It takes the network config's ownallowedDomains/deniedDomains/deniedResolvedAddressesand 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 typedResolvedAddressDeniedError. Generic IP/CIDR helpers (parseAddressRange,addRange,addressInSet, loopback predicates) live in a new leafsrc/sandbox/address.ts, which the parent-proxy NO_PROXY parser and config validation now share.Denied set (hostnames only):
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));os.networkInterfaces(), read per lookup) — loopback is only the most common spelling of "this host"; a service bound to0.0.0.0/::answers on the LAN, VPN or global-IPv6 address just the same, and no private-range list covers a global address;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 byBlockListitself;embeddedIPv4()inaddress.tsextracts 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 is0.0.0.1, cannot ride a carve-out for0.0.0.1). The local-use prefix64:ff9b:1::/48and 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 as64:ff9b::7f00:1,64:ff9b::a9fe:a9feor64:ff9b::<this host's LAN address>(reachable wherever a NAT64 gateway, or a 6to4 /sitinterface, 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/104→10.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/128matches 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;addRangealso reports a rule the runtime refuses as not-added rather than throwing, which keeps the NO_PROXY parser as tolerant as it was. AndcanonicalizeHostnow 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) as127.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:
127.0.0.1:3000or[::1]is an explicit choice; the guard only applies to names.allowedDomains— reaching it by name grants nothing the literal entry did not. A literal indeniedDomainsstill wins, in the order the name filter uses (deny-list literal, allow-list literal, thelocalhostrule, then the denied set):allowedDomains: ["127.0.0.1", "myapp.test"]withdeniedDomains: ["127.0.0.1:6379"]refusesmyapp.test:6379exactly as it refuses the literal. The dev setup wheremyapp.testmaps to a local server via/etc/hostsis["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.localhostand 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.deniedResolvedAddressesrather 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 })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, andhttp(s).requestis then made to that address with the name carried inHostand, for TLS,servername(SNI and certificate verification stay on the hostname). So the vetted address is still the one used, with no second resolution — andlookupis deliberately not handed to the HTTP client: Bun'snode:httpclient up to 1.3.x (CI pins 1.3.14) resolves through a customlookupbut then drops or repeats a streamed request body, and ignorescreateConnection(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 useagent: false— the global agent is shared with the embedding process (and Bun caches the first request'scaon it), and the vetting dial runs per request anyway, so a pool would buy nothing but the second connect. The terminated leg now setsHostfrom the tunnel's target (the name the allowlist saw) rather than deleting it and letting the runtime derive it;formatAuthority()is the one spelling ofname[:port](IPv6 bracketed, default port elided) for that Host, the plain leg's Host, and the terminated leg'sfilterRequestURL (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 reply0x02(connection not allowed by ruleset) instead of "host unreachable". The 403 writers (rawDenied,respondDenied,respondUpstreamError) live inrequest-filter.tsand 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 alocalhostname — 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)takeslookupas 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
parentProxyor themitmProxysocket 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 macOSallowLocalBindingseparately 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 fromaddress.ts.Tests
test/sandbox/resolved-address-guard.test.ts(43 tests):embeddedIPv4extraction table (mapped, compatible, translated, NAT64 well-known and local-use /96, 6to4, and the look-alikes that are not);permitstable: 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 thelocalhostrule; public and private-use addresses permitted by default; IP-literal destinations never re-judged;localhostnames ↔ loopback only; embedder-denied ranges; port-scoped allow and deny rules; rules derived from the IP-literal entries ofallowedDomains/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;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 andlocalhoststill connect; a permitted resolution is dialed at the resolved address with theHostheader preserved; a streamed, chunked-reframed POST reaches the upstream intact and exactly once; an embedder-configured range is refused;403inside 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 iflookupis handed tohttp(s).requestinstead.)The end-to-end credential-masking suites (
credential-mask,credential-mask-env,credential-mask-files, Linux) usedlocaltest.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--resolvehints 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.localhostper RFC 6761 — and these suites run on Linux only.canonical-host-routinggains a case where the IPv4-mapped spelling of a deny-listed address is itself allow-listed and still refused;parent-proxygains canonicalisation cases for mapped literals and NO_PROXY cases for zoned and mapped entries (a zoned entry threw under Bun once the parser sharedaddRange; a mapped one bypassed the parent proxy for every IPv4 host). The Windows CI job runs only thewinsrtandmux-proxysuites, 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.localhostrename described above). The four dial paths were exercised under Node 24 against the builtdist/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 buildand prettier are clean.Manual verification (macOS arm64; built
dist/at2b8c021— and earlier at0bc5c94/73a5805with identical results — vsmainat66d35e5; Node 24.18 and Bun 1.3.14). The proxy stack was started in-process viaSandboxManager.initialize()and driven withcurl -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 on127.0.0.1:18080and0.0.0.0:18081, an echo server (body length + sha256) on127.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-processdns.lookuptable 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, bodyConnection to 127-0-0-1.nip.io blocked: resolved to denied address 127.0.0.1, violationdeny 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 to169.254.169.254,0.0.0.0and the machine's own LAN address (listener on0.0.0.0never reached). Onmaineach 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 literal127.0.0.1:18080→ the usual allow-list 403 unless listed."127.0.0.1:18080":127-0-0-1.nip.io:18080→ 200 and127-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 to198.51.100.0(real resolver) → 403resolved to denied address 198.51.100.0; without the entry the proxy dials it.localhost:18082— 7-byte body in the header write, 7-byte chunked, 1 MiBContent-Length, 1 MiB chunked — arrives intact (sha256 match) exactly once under Node and Bun 1.3.14. The same run against8cb623b^under Bun 1.3.14 delivers the 7-byte body empty and the small chunked POST never reaches the upstream.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 answering64:ff9b::7f00:1,64:ff9b::a9fe:a9fe,2002:7f00:1::1and::7f00:1→ 403 with one violation each (the pre-fix head dialed all four); a name answering64: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"), orlocalhost. Nothing changes for IP-literal allowlist entries, forlocalhost, or for traffic that leaves viaparentProxy/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 indeniedResolvedAddressesbroad 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
Hostitself and relies onservernamefor SNI and certificate verification (whether or not a resolved-address lookup is wired). The upstream certificate is verified with an explicitcheckServerIdentitypinned to the tunnel's target host, so the identity checked is the name the allowlist saw regardless of how a runtime maps theHostheader or SNI to it. Node and Bun ≥ 1.3.11 (CI pins 1.3.14) honour that; Bun ≤ 1.3.x ships a defectivenode:http/node:httpsclient (the same defect the direct legs re-dial around, oven-sh/bun#7471) that verifies against theHostheader verbatim and ignorescheckServerIdentity, 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