Skip to content

proxy: match bare IPv6 NO_PROXY entries against bracketed URL hosts - #37429

Closed
robobun wants to merge 4 commits into
mainfrom
farm/0ebeccf0/no-proxy-ipv6-brackets
Closed

robobun wants to merge 4 commits into
mainfrom
farm/0ebeccf0/no-proxy-ipv6-brackets

Conversation

@robobun

@robobun robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

With a proxy configured, a NO_PROXY entry holding a bare IPv6 address does not exempt requests to that address. Only the bracketed spelling does:

// proxy on 127.0.0.1 answers everything itself with 599; origin on ::1 answers 200
process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`;
for (const np of ["::1", "[::1]", "localhost,::1", "*"]) {
  process.env.NO_PROXY = np;
  console.log(np, (await fetch(`http://[::1]:${originPort}/`)).status);
}
::1            599   <- sent through the proxy
[::1]          200
localhost,::1  599   <- sent through the proxy
*              200

new WebSocket("ws://[::1]:port", { proxy }) behaves the same way, and so does anything else that goes through the env loader (bun install against an IPv6 registry, bun upgrade, bun create).

The bare form is the one people actually write: NO_PROXY=localhost,127.0.0.1,::1 is what curl, Go (docker and friends), pip and Node's node:http proxy support read, and it is the value typical proxied CI / dev containers ship with. Bun's own node:http port already accepts it (it compares the unbracketed host), so http.get("http://[::1]/") bypassed the proxy while fetch("http://[::1]/") in the same process did not.

Cause

Both NO_PROXY matchers, Loader::is_no_proxy in src/dotenv/env_loader.rs (WebSocket via Bun__isNoProxy, install, upgrade, create) and its copy no_proxy_matches in src/http/lib.rs (fetch hop 0 and redirect hops, added in #33651), compare the hostname exactly as the URL parser hands it over. For an IPv6 literal that is the bracketed string [::1], so the entry only matches when it is written with brackets too.

Fix

The two copies are folded into one bun_dotenv::no_proxy_matches, which Loader::is_no_proxy and bun_http::ProxySettings::resolve both call. When the URL hostname is a bracketed IPv6 literal, the brackets are removed from it and from the entry before comparing, so ::1 and [::1] both match http://[::1]/. Accepting both is a strict superset of what Bun accepted before (undici's EnvHttpProxyAgent reads the bracketed form, so existing [::1] entries keep working) and adds the form the rest of the ecosystem reads; an entry with two or more colons, bracketed or not, cannot mean anything other than an IPv6 address, so nothing that previously matched changes meaning.

Two deliberate details:

  • An address entry is matched exactly, never as a domain suffix: there is no .-boundary suffix semantic for an address (curl does address matching only for IP targets as well). Previously the suffix rule was applied to the bracketed string; with a canonical IPv6 serialization it could never match, so no observable behavior is lost.
  • [::1]:8080 still matches the URL's host ([::1]:8080) as before. A bare entry never carries a port, because ::1:8080 is itself a valid IPv6 address; curl and Go read it the same way.

Matching stays textual, as before and as in Node's http and undici: an entry has to use the same spelling as the URL host (::1 for http://[::1]/, which is also what the WHATWG serialization produces for fetch and WebSocket; bun install compares against the registry URL as written). Address-level comparison of alternative spellings such as 0:0:0:0:0:0:0:1 would be a separate change.

Tests

All IPv6 blocks are gated on a new harness.hasIPv6Loopback(), a bind probe of ::1. isIPv6() was not used because it is pinned to false on BuildKite Linux even though the loopback binds there (bun-server.test.ts binds ::1 unconditionally on every lane), so it would have skipped these tests on the lanes that matter.

test/js/bun/http/proxy.test.ts, new block "NO_PROXY matches an IPv6 literal with or without brackets": a subprocess fetches http://[::1]:<origin>/ with http_proxy pointing at a proxy that answers every request itself, so the response body says which route was taken. Rows: ::1, localhost,127.0.0.1,::1,0.0.0.0, 127.0.0.1, ::1, [::1], [::1]:<port> go direct; ::2, [::1]:1, ::1:<port>, 1 still go through the proxy. Three rows against http://[::ffff:7f00:1]/ (the v4-mapped loopback, reaching an origin on 127.0.0.1; skipped on Windows, whose sockets are IPv6-only by default) give the address compare hex digits in both cases: ::FFFF:7F00:1 and [::ffff:7f00:1] go direct, ::ffff:7f00:2 does not. Two more tests cover the explicit fetch(url, { proxy }) option and a 302 from 127.0.0.1 into http://[::1]/ (the redirect re-evaluation path in bun_http).

test/js/web/websocket/websocket-proxy.test.ts, new "IPv6 literal target" rows in the existing NO_PROXY block: ws://[::1]:<port> with an explicit auth-requiring proxy and NO_PROXY set to ::1, localhost,127.0.0.1,::1, [::1] (connects) and ::2 (407 from the proxy). This is the Bun__isNoProxy entry point into the env loader.

test/cli/install/bun-info.test.ts, new block: bun info against a registry on [::1] with http_proxy pointing at a proxy that 404s everything, for NO_PROXY = ::1, localhost,127.0.0.1,::1, [::1] (prints the package name) and ::2 (fails with the proxy's 404). This is the Loader::get_http_proxy_for entry point, which install, audit, upgrade and create share and which neither fetch nor WebSocket goes through.

On the released binary the bare-form rows fail (6 in proxy.test.ts, 2 in websocket-proxy.test.ts, 2 in bun-info.test.ts) and the bracketed and negative rows pass; with this change all three files pass in full.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/proxy.test.ts

…ut brackets

A URL hands the NO_PROXY matcher the bracketed IPv6 hostname ("[::1]" for
http://[::1]/), but NO_PROXY lists conventionally hold the bare address
(NO_PROXY=localhost,127.0.0.1,::1), so fetch, WebSocket, bun install and
friends kept sending such requests through the proxy unless the entry was
spelled "[::1]". Strip the brackets from both the hostname and the entry
before comparing, so either spelling bypasses; "[::1]:port" entries still
match the host:port form. An address entry is only ever matched exactly,
never as a domain suffix.

The matcher was duplicated between bun_dotenv::Loader::is_no_proxy (env
backed callers) and bun_http::ProxySettings (fetch's captured copy, hop 0
and redirects); both now share bun_dotenv::no_proxy_matches.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 261e7c4d-b157-4c62-9164-8351f28c9361

📥 Commits

Reviewing files that changed from the base of the PR and between cd15fba and 12310cb.

📒 Files selected for processing (7)
  • src/dotenv/env_loader.rs
  • src/dotenv/lib.rs
  • src/http/lib.rs
  • test/cli/install/bun-info.test.ts
  • test/harness.ts
  • test/js/bun/http/proxy.test.ts
  • test/js/web/websocket/websocket-proxy.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the released binary (1.4.0, linux x64) with a proxy on 127.0.0.1 and an origin on ::1: NO_PROXY=::1 and NO_PROXY=localhost,::1 still routed fetch("http://[::1]:<port>/"), new WebSocket("ws://[::1]:<port>", { proxy }) and bun info (registry on [::1]) through the proxy; NO_PROXY=[::1] did not.

The bare-address rows added in test/js/bun/http/proxy.test.ts (6), test/js/web/websocket/websocket-proxy.test.ts (2) and test/cli/install/bun-info.test.ts (2) fail on the released binary for exactly those entries; all three files pass in full with this branch.

Comment thread test/js/bun/http/proxy.test.ts Outdated
Adds harness.hasIPv6Loopback(), a bind probe of ::1, and gates both new
blocks on it. isIPv6() is pinned to false on BuildKite Linux even though
loopback binds there, so it would skip the tests on the lanes that matter.
Comment thread src/dotenv/env_loader.rs Outdated
Comment thread src/dotenv/env_loader.rs Outdated
Comment thread src/dotenv/env_loader.rs Outdated
Comment thread src/dotenv/env_loader.rs Outdated
@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:40 AM PT - Aug 11th, 2026

✅ @robobun, your commit 12310cbbd2a6aa0841c4cc780046991d18fe81da passed in Build #92158! 🎉


🧪   To try this PR locally:

bunx bun-pr 37429

That installs a local version of the PR into your bun-37429 executable, so you can run:

bun-37429 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the earlier IPv6-gating and comment-length feedback are both addressed, and no new issues found on re-review.

Checked that the consolidated no_proxy_matches preserves the old bun_http behavior for non-IPv6 hostnames (the IPv6 branch is only entered when strip_ipv6_brackets(hostname) is Some), and that is_no_proxy's host.unwrap_or(b"") is equivalent to the previous if let Some(h) = host guard. bun_http already depends on bun_dotenv, so no new crate edge. describe.concurrent.skipIf(...) and using _ = Bun.listen(...) in hasIPv6Loopback() both match existing test patterns.

Extended reasoning...

Overview

Folds the two duplicate NO_PROXY matchers (Loader::is_no_proxy in src/dotenv/env_loader.rs and the copy in src/http/lib.rs) into one exported bun_dotenv::no_proxy_matches, and teaches it to strip brackets from an IPv6 URL hostname so a bare ::1 entry matches http://[::1]/. Adds a hasIPv6Loopback() harness probe and IPv6-gated test blocks in proxy.test.ts and websocket-proxy.test.ts.

Security risks

NO_PROXY governs proxy bypass. The change is a strict superset for IPv6 targets (a bare-address entry that previously failed to match now matches), aligning with curl/Go/pip/Node's node:http. Nothing that previously went direct now goes through the proxy, and for non-IPv6 hostnames the domain-suffix branch is byte-identical to before. No new untrusted-input parsing beyond what was already present.

Level of scrutiny

Moderate. The matcher consolidation is a small, mechanical dedup with one new branch; the crate dependency (bun_http → bun_dotenv) already existed. I traced the non-IPv6 path against both removed implementations and it is unchanged; host.unwrap_or(b"") is observationally equivalent to the old if let Some(h) guard since the entry is never empty in the port branch.

Other factors

My prior review's IPv6-availability concern was addressed with hasIPv6Loopback() (bind probe of ::1, cached), which the author justified over isIPv6() — a reasonable trade-off since isIPv6() is hard-pinned to false on BuildKite Linux where the loopback does bind. The comment-cop feedback on long comments in env_loader.rs was addressed in 594d329. Test coverage is thorough (bare/bracketed/port/negative rows across env-proxy, explicit {proxy}, redirect re-resolve, and the WebSocket/env-loader path), and robobun confirmed the bare-address rows fail on the released binary.

bun info against a registry on [::1] exercises Loader::get_http_proxy_for,
which fetch and WebSocket do not go through. The v4-mapped rows in
proxy.test.ts give the address compare hex digits in both cases; they are
skipped on Windows, whose sockets cannot reach a v4-mapped address.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed after c3a25d7 / 594d329 / 12310cb and didn't find any further issues — the hasIPv6Loopback() bind probe is a better answer than the isIPv6() guard I'd suggested, and the three IPv6 blocks (proxy.test.ts, websocket-proxy.test.ts, bun-info.test.ts) are now all gated on it. Because this changes proxy-bypass semantics for NO_PROXY across fetch/WebSocket/install, a maintainer sign-off on the matching rules would still be worthwhile.

What was reviewed:

  • no_proxy_matches: bracket-stripping only fires when the URL hostname is itself a bracketed literal, so non-IPv6 targets keep the exact prior suffix path; degenerate entries ([::1, []) fall through without over-matching.
  • bun_http → bun_dotenv call replaces a byte-for-byte duplicate; bun_http already depended on bun_dotenv, so no new layering.
  • New tests drain pipes concurrently, use port: 0, clear ambient proxy env per case, and cover the negative rows (::2, [::1]:1, ::1:<port>, 1) so the matcher can't silently widen.
Extended reasoning...

Overview

Folds two copied NO_PROXY matchers (Loader::is_no_proxy in src/dotenv/env_loader.rs and no_proxy_matches in src/http/lib.rs) into one exported bun_dotenv::no_proxy_matches, and teaches it to strip [...] from both the URL hostname and the NO_PROXY entry when the target is an IPv6 literal. Adds hasIPv6Loopback() to test/harness.ts and new gated IPv6 test blocks in proxy.test.ts, websocket-proxy.test.ts, and bun-info.test.ts.

Security risks

NO_PROXY decides whether a request bypasses the configured proxy, so widening its match set is security-adjacent. The widening is narrowly scoped: it only applies when the URL hostname is a bracketed IPv6 literal, and only strips brackets from entries that are themselves bracketed — a bare entry with 2+ colons is already unambiguously an address. The domain-suffix branch is now explicitly skipped for IPv6 targets (previously it ran but could never match on [::1] for lack of a . boundary), so no domain-name entry can accidentally exempt an IPv6 host. Entries that matched before still match; the only new matches are bare-address ↔ bracketed-host, which curl/Go/Node/undici already accept.

Level of scrutiny

Medium-high. The core logic change is ~25 lines and mechanically sound, and the deduplication is exactly what REVIEW.md asks for ("one implementation, in the right place"). But proxy-bypass routing is the kind of behavior a maintainer should sign off on rather than a bot, and the PR also introduces a new harness helper whose interaction with the BuildKite-Linux isIPv6() pin is a judgment call the author made deliberately.

Other factors

My prior inline comment (unguarded ::1 bind in beforeAll) was addressed with a bind-probe helper rather than isIPv6(), with a reasoned explanation (isIPv6() is pinned false on BuildKite Linux where ::1 does bind, so it would skip the tests everywhere they matter). The comment-cop verbose-comment flags were addressed by trimming to single lines. All review threads are resolved. Test coverage spans all three entry points (fetch env-proxy + explicit option + redirect hop, WebSocket via Bun__isNoProxy, package-manager via get_http_proxy_for) with both positive and negative rows. CI build #92158 was still in progress at review time.

Jarred-Sumner pushed a commit that referenced this pull request Aug 22, 2026
…40093)

### Problem
- `test/js/bun/http/bun-server.test.ts` takes 9 to 16 s on every CI
lane. The time is fixed waits, not work.
- The GC stress test runs 400 rounds or 8 s, and since #37074 it tests
nothing: a graceful `stop()` closes the idle parked connections, so
every late request meets a closed socket (0 of 4770 dispatched on main).
- Other fixed costs: 3 x 1000 ms CPU samples during the echo burst,
30-iteration GC loops with 10 ms sleeps, two 100 ms timers in the HEAD
probe.

### Fix
- The stress test targets node:http, whose `close()` leaves busy
connections keep-alive afterwards, as in Node. Each round holds a
request per parked connection across `close()`, drops the binding, runs
`Bun.gc(true)`, and requires every late request to be answered by the
original handler. With the downgrade gate removed it fails in round 1 (3
of 3). On main 162 of 162 late requests are answered.
- Every other timer becomes the event it waited for (list in the notes).
Top-level tests run concurrently where `test.concurrent(` fits on one
line.
- Stronger checks on full responses, `pendingRequests` around `stop()`,
exact status lines, and piped `stdout`/`stderr`/`exitCode` for every
fixture (list in the notes).
- Verified, same machine and build: debug+ASAN 88.8 s to 25 s, release
10.4 s to 2.1 s, Windows x64 canary 11.1 s to 2.1 s. Pass set unchanged.

### Background
- `Bun.gc(false)` is `Heap::collectSync`: collection only.
`Bun.gc(true)` is `Heap::collectNow(Sync)`, which also sweeps, so dead
cells with destructors are finalized inside the call.
- The downgrade gate (#35130): the JS wrapper is the handlers' only GC
root. `is_drained()` lets it go Weak only when no request, listener,
websocket, or connection is left. Without it the wrapper is collected
while connections are open and its finalizer closes them.
- `heapStats().objectTypeCounts` counts prototypes with instances.
`count - 1` while one instance is alive is the per-build floor the old
30-iteration drain produced.

<details><summary>Notes</summary>

Runs of this revision: debug+ASAN `bun bd test` 2 runs (25.5 s, 26.2 s),
release `USE_SYSTEM_BUN=1` 5 runs (2.11 to 2.16 s), Windows x64 canary
build of the same commit 4 runs (2.10 to 2.12 s, 79 pass). In this
container three tests fail on main and on this branch alike (`rejected
promise handled by error method`, `parse source map and fetch small
stream`, `abrubtly close a upload request`): they bind `hostname:
"localhost"`, which lands on `::1` here while the client dials
`127.0.0.1`. #38818 fixes that in bun, so the fixtures stay as they are.
The local runs unset `HTTP_PROXY`/`HTTPS_PROXY`: this container's egress
proxy does not honor a bare `::1` in `NO_PROXY` for the new
`http://[::1]:port/` request (#37429). CI has no proxy.

Stress fixture evidence:
- main's fixture on main (release, 400 rounds): answered 0, closed 4770.
Debug build, 40 rounds: answered 0, closed 366.
- PR fixture on main: answered 162 of 162, release (about 160 ms) and
debug (5.8 s standalone).
- gate removed (`is_drained()` returns `is_closed()`), debug build:
`FAIL round 1: a parked connection closed`, 3 of 3. The finalizer's
`app.close()` lands before the same round's late requests are answered.
- bun 1.3.14 is not a control for the new fixture: its node:http
`close()` closes busy connections too.
- The first revision kept the Bun.serve fixture with `Bun.gc(true)` and
16 rounds (round-2 detection on 1.3.14, 60 of 60, versus round 15 to 153
with `Bun.gc(false)`). Review found the late requests unreachable on
main, hence the retarget.

Assertions added: `{status, text}` on plain responses, `pendingRequests`
before and after `stop()` plus the exact status line in the drain
fixtures (`HTTP/1.1 200 OK`, `HTTP/1.1 500 Internal Server Error`, `""`
for the force-closed connection), the negotiated subprotocol and the
close frame in the custom-protocol test, `AbortError` plus the exact
byte count for the truncated upload, `redirected`/`url` on the redirect,
`Completed: 10` from the unref fixture, and `{stdout, stderr, exitCode}`
via `toEqual` for every spawned fixture. The nitro fixture runs with
`PORT=0` instead of port 3000.

Concurrency: six long-named tests (or tests with a timeout argument)
stay sequential `test(` because prettier reflows `test.concurrent(`
calls that exceed 120 columns and would reindent their bodies. The two
`await server.stop()` tests also stay sequential: they assert that a
fetch to the freed port fails.

Timers replaced by events: the abort-signal stream tests await the
server-side `abort`; the truncated upload half-closes once the server
consumed the chunk; the HEAD probe sends `Connection: close` and reads
to `end`; the CPU fixture samples 3 x 500 ms after the last echo; the
heapStats loops poll `objectTypeCounts` with a 5 s deadline.

`bun test` runs consecutive concurrent tests as one group (20 at a time,
5 under ASAN); a plain `test(` ends the group.

Fixed waits removed: the `Bun.sleep(15)`/`Bun.sleep(10)` pairs in the
two abort-signal stream tests, `setTimeout(..., 100)` in the upload test
and in `doHead`, the `Bun.sleep(10)` in six GC loops, the 8 s floor of
the stress test, the 1000 ms CPU windows, and the 30-iteration
`drain(0)` baseline loops. The `Bun.sleep(15)` in "abort signal on
server should only fire if aborted" stays: a negative check with no
event to await, inside a concurrent block.

`Bun.spawnSync` in two "Server" tests blocked the event loop of the
concurrent block. Both use `Bun.spawn` now. `tryWritePending` kept the
written prefix on a partial write (`data.slice(0, written)`). It keeps
the unsent tail now.

Release per-test after the change: the idle-CPU fixture (1.5 s) bounds
the file. Everything else is under 0.2 s. No file under `src/` changes.
The edited CPU fixture is referenced only by this test file.
</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/http/bun-server.test.ts

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Bare and bracketed IPv6 NO_PROXY entries match on main since #42692, which also folded the two matchers into one (bun_dotenv::no_proxy::matches), as this PR did. On main (55c1106), NO_PROXY=::1 and NO_PROXY=localhost,::1 send fetch, WebSocket and bun info requests for [::1] direct. Closing this PR as superseded.

@robobun robobun closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants