Skip to content

http: proxy stress test suite + fix streamed upload and on_writable UAF through tunnel#32635

Merged
Jarred-Sumner merged 16 commits into
mainfrom
farm/743ae77b/proxy-stress-tests
Jun 23, 2026
Merged

http: proxy stress test suite + fix streamed upload and on_writable UAF through tunnel#32635
Jarred-Sumner merged 16 commits into
mainfrom
farm/743ae77b/proxy-stress-tests

Conversation

@robobun

@robobun robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Comprehensive stress testing of the HTTP client proxy code paths (fetch and WebSocket, both ProxyTunnel and WebSocketProxyTunnel): 661 tests across 5 new files plus a shared adversarial proxy/origin helper and a subprocess memory-probe fixture.

File Tests Covers
proxy-stress-helpers.ts n/a Adversarial CONNECT + absolute-form proxy (http/https outer) with per-stage RST/kill/trickle/split hooks; adversarial origin with content-length / chunked / close-delimited × identity/gzip/deflate/br/zstd, truncation at arbitrary byte offset, redirect, echo.
proxy-stress-matrix.test.ts 335 {http,https} proxy × {http,https} origin × framing × encoding × body-size × keepalive response matrix; upload matrix across string/Uint8Array/Blob/FormData/ReadableStream/async-iterator; streamed response via getReader(); trickled 1-byte-per-tick downstream; split CONNECT envelope; RFC 9110 §9.3.6 ignored-header handling; hop-by-hop stripping; method matrix; cross-scheme redirects.
proxy-stress-lifecycle.test.ts 93 Proxy RSTs client at every tunnel stage (request-received / upstream-connected / connect-replied / first-client-byte / first-upstream-byte) × both origins × both keepalive; same during upload (string + stream body); proxy drops upstream at every stage; origin RST at 0/10/60/200 response bytes; close-delimited × compression; abort at every stage; abort after headers; abort churn (200× under ASAN); ASAN-only TLS-alert-during-handshake UAF repro.
proxy-stress-errors.test.ts 51 CONNECT 400/403/407/500/502/503/504/301/302; proxy unreachable; upstream unreachable (502 via CONNECT and absolute-form); proxy auth (missing/wrong/correct, URL and header) for all 4 combos; inner-TLS verify (CA match, no-CA fail, checkServerIdentity reject); unsupported scheme (ftp/socks4/socks5/socks5h/ws); h2-capable origin through proxy stays HTTP/1.1.
proxy-stress-concurrent.test.ts 31 32× parallel per combo × keepalive; tunnel reuse (sequential + auth-keyed); 12 origins through one proxy; reject_unauthorized pool gate (lax → strict forces fresh CONNECT); 4× concurrent 4MB echo; idle pooled tunnel receiving stray data is evicted; 12 subprocess memory probes (6 modes × 2 proxy schemes, 300-1200 iterations each, RSS-growth bounded).
proxy-stress-adversarial.test.ts 151 Stacked split-CONNECT × chunked × compression × keepalive; origin status 200-503 × all combos; origin-facing Host/header shape; checkServerIdentity approve/reject/GC-loop; path/query edges; verbose:true; AbortSignal.timeout; interleaved proxy/direct to same origin; CONNECT target shape; WebSocket proxy matrix (ws/wss × http/https proxy) with echo, 256KB binary, RST at CONNECT, 407 without auth, rapid close, wss-via-https-proxy open/close churn under GC.

Full suite runs in ~5 min under debug+ASAN.

Bugs found and fixed

Two pre-existing bugs surfaced by the suite (both reproduce on main without any test-helper changes). Fixed here in src/http/lib.rs.

1. Streamed request body through a CONNECT tunnel never sent

fetch() to an https:// origin through any proxy with a ReadableStream / async-iterator body hangs forever: CONNECT succeeds, inner TLS handshake completes, the Transfer-Encoding: chunked request head is written, then nothing. String/Uint8Array/Blob/FormData bodies are fine; http:// origins (absolute-form, no tunnel) are fine.

Cause (two layers):

  • The RequestStage::ProxyHeaders arm of on_writable computes has_sent_body = self.request_body().is_empty(). For HTTPRequestBody::Stream the bytes buffer is always empty here, so the request jumps straight to RequestStage::Done and the is_streaming_request_body signal path a few lines below never runs. The non-proxy send_initial_request_payload already gates this on matches!(original_request_body, HTTPRequestBody::Bytes(_)).
  • Even once ProxyBody is reached, its Stream case calls flush_streamwrite_to_stream_using_bufferwrite_to_socket(socket, …), which writes plaintext chunked bytes to the outer proxy socket instead of through the inner TLS session. The Bytes case right next to it correctly routes through ProxyTunnel::write.

Fix: mirror the non-proxy path's Bytes-only has_sent_body check (and matching debug_assert) in the ProxyHeaders arm; in write_to_stream_using_buffer, route through ProxyTunnel::write when self.proxy_tunnel.is_some(), treating WantRead/WantWrite from the inner SSL as backpressure. Encrypted output reaches the outer socket via the existing write_encrypted callback, same as the Bytes path. This also covers the HTTPThread drain loop which calls the same flush_stream.

2. heap-use-after-free in HTTPClient::on_writable when a TLS alert is buffered with the inner handshake flight

READ of size 1 thread T12 (HTTP Client)
  #0 HTTPClient::on_writable::<true, false>    src/http/lib.rs:~2856
  #1 bun_http::proxy_tunnel::on_handshake      src/http/ProxyTunnel.rs:450
freed by:
  AsyncHTTP::on_async_http_callback_raw        src/http/AsyncHTTP.rs:813
  HTTPClient::close_and_fail::<false>
  bun_http::proxy_tunnel::on_close             src/http/ProxyTunnel.rs:577
  SSLWrapper::handle_reading                   src/uws/lib.rs:1053
  SSLWrapper::flush                            src/uws/lib.rs:669
  ProxyTunnel::on_writable::<false>            src/http/ProxyTunnel.rs:740
  HTTPClient::on_writable::<true, false>       src/http/lib.rs:~2850
  bun_http::proxy_tunnel::on_handshake         src/http/ProxyTunnel.rs:450

If the origin closes or sends a TLS alert in the same buffer as its ServerHello flight (origin rejecting client cert, corrupted stream via misbehaving proxy, origin crash), the client's on_handshake → on_writable → proxy.on_writable → SSLWrapper::flush → handle_reading chain processes the alert, fires on_close → close_and_fail, which runs the result callback and frees the ThreadlocalAsyncHTTP embedding *self. Control returns to on_writable, which immediately reads self.state.flags.is_waiting_for_cert_check on freed memory. Same bug class already documented in start_proxy_handshake's comment.

Fix: close_and_failterminate_socket synchronously marks the outer socket closed, and the socket handle is owned by the event loop (outlives the client). Check socket.is_closed() immediately after proxy.on_writable() and return before touching self.

Deterministic ASAN repro in proxy-stress-lifecycle.test.ts ("TLS alert in same buffer as inner handshake"): a CONNECT proxy that double-writes every client→upstream byte, making the origin's TLS stack abort with an alert. Looped 20× with ASAN_OPTIONS=…:abort_on_error=1 so the HTTP-thread UAF aborts the subprocess before the main thread's clean exit wins the race.

Verification

bun bd test test/js/bun/http/proxy-stress-*.test.ts
# 661 pass, 0 fail (debug+ASAN, ~5min)

Fail-before (src/ stashed, bun bd):

  • proxy-stress-matrix.test.ts -t "https-origin POST ReadableStream|https-origin POST async-iterator" → 8 fail (hang/timeout)
  • proxy-stress-lifecycle.test.ts -t "TLS alert in same buffer" → 1 fail (subprocess aborts with heap-use-after-free)

Existing test/js/bun/http/proxy.test.ts (48 tests) still passes.

Related

…AF through tunnel

Adds comprehensive stress testing of the HTTP client proxy code paths:
661 tests across 5 new files plus a shared adversarial proxy/origin helper.

  test/js/bun/http/proxy-stress-helpers.ts      adversarial proxy + origin
  test/js/bun/http/proxy-stress-matrix.test.ts  protocol x framing x encoding x body x keepalive
  test/js/bun/http/proxy-stress-lifecycle.test.ts  RST/abort/close at every tunnel stage
  test/js/bun/http/proxy-stress-errors.test.ts  CONNECT failures, auth, unreachable, TLS verify
  test/js/bun/http/proxy-stress-concurrent.test.ts  pool churn + subprocess memory probes
  test/js/bun/http/proxy-stress-adversarial.test.ts  stacked conditions + WebSocket proxy tunnel

The adversarial proxy supports both CONNECT tunneling and absolute-form
forwarding over a plain-TCP or TLS outer socket, with hooks to RST at any
stage, split/trickle bytes, force CONNECT status codes, require auth, and
track per-connection wire state. The adversarial origin shapes responses
(content-length / chunked / close-delimited, all compression encodings)
and can truncate at any byte offset.

Two pre-existing bugs surfaced by the suite and fixed here in
src/http/lib.rs:

1. Streamed request body (ReadableStream / async-iterator) through a
   CONNECT tunnel to an https origin hangs forever. The ProxyHeaders arm
   of on_writable computed has_sent_body from request_body().is_empty(),
   which is always true for HTTPRequestBody::Stream, so the request
   jumped to Done before the stream was ever signalled. Even with that
   fixed, write_to_stream_using_buffer wrote streamed body bytes to the
   outer socket instead of through the inner TLS session. Fixed by
   matching send_initial_request_payload's Bytes-only has_sent_body
   check and routing write_to_stream_using_buffer through
   ProxyTunnel::write when a tunnel is active.

2. heap-use-after-free in HTTPClient::on_writable when a TLS alert is
   buffered alongside the inner handshake ServerHello flight.
   on_handshake -> on_writable -> proxy.on_writable -> SSLWrapper::flush
   -> handle_reading processes the alert, fires on_close ->
   close_and_fail, which frees the ThreadlocalAsyncHTTP embedding *self
   via the result callback; control returns to on_writable and touches
   freed self. Fixed by checking socket.is_closed() after
   proxy.on_writable() before touching self again (same hazard already
   documented in start_proxy_handshake).
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:10 PM PT - Jun 23rd, 2026

@robobun, your commit 554db3e has 1 failures in Build #64297 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32635

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

bun-32635 --bun

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Independently reproduced and fixed the on_writable UAF (bug 2 in this PR) on farm/4bb9eabe/proxy-tunnel-on-writable-uaf; arrived at the identical socket.is_closed() guard after proxy.on_writable. Fail-before/pass-after verified under ASAN against current main (c6be834), so the guard still applies cleanly after a rebase. Not opening a separate PR.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Fixes the HTTPClient proxy-tunnel write path in src/http/lib.rs by routing buffered stream bytes through ProxyTunnel::write instead of the outer socket, adding a socket.is_closed() liveness guard in on_writable, and correcting has_sent_body semantics for non-Bytes bodies in the ProxyHeaders stage. Backs the fixes with a new shared adversarial harness (proxy-stress-helpers.ts) and ten new test suites covering matrix, error, protocol, lifecycle, adversarial, and concurrent scenarios.

Changes

Proxy tunnel fixes and stress test coverage

Layer / File(s) Summary
Proxy tunnel write path, liveness guard, and ProxyHeaders body-type fix
src/http/lib.rs
Routes stream-buffer bytes through ProxyTunnel::write with backpressure in write_to_stream_using_buffer; adds an early-return after proxy.on_writable when socket.is_closed(); makes has_sent_body conditional on body type (Bytes vs Stream/Sendfile) and updates the ProxyHeaders debug assertion accordingly.
Adversarial proxy and origin test harness
test/js/bun/http/proxy-stress-helpers.ts
Adds createAdversarialProxy (with lifecycle stage hooks, split CONNECT replies, trickle, RST timing, auth enforcement) and createAdversarialOrigin (configurable status, framing, encoding, echo, RST-after-bytes), plus env-hygiene utilities (clearProxyEnv, restoreProxyEnv, proxyFreeEnv) and shared helpers (laxTls, makeBody, errcode, deadPort, cartesian).
Memory/UAF subprocess fixture
test/js/bun/http/proxy-stress-memory-fixture.ts
Standalone subprocess that creates a local CONNECT proxy and HTTPS origin, runs fetch+optional-abort iterations across multiple modes with a warmup/GC phase, and emits a JSON RSS summary before exiting.
Protocol matrix and upload shape tests
test/js/bun/http/proxy-stress-matrix.test.ts
Exercises combinatorial response matrix (TLS × framing × encoding × body size × keepalive), upload shapes (string/Uint8Array/Blob/FormData/ReadableStream/async-iterator), streamed response via getReader(), trickled bytes, split CONNECT reply, ignored CONNECT headers, hop-by-hop header stripping, HTTP method matrix, and redirect-through-proxy.
Header handling and response consumption tests
test/js/bun/http/proxy-stress-headers.test.ts
Validates preservation of custom response headers, duplicate Set-Cookie handling, Content-Type preservation, 206 Partial Content with Content-Range, decompress: false behavior, CONNECT envelope headers, long request paths, tunnel pool separation, and default decompression (body decoded, Content-Encoding retained).
Error surface and protocol-failure tests
test/js/bun/http/proxy-stress-errors.test.ts
Validates CONNECT non-200 status propagation without origin contact, refused proxy/upstream connection errors, proxy auth (407/403/200), inner-TLS verification (CA match, no-CA failure, checkServerIdentity rejection), unsupported proxy schemes, and HTTP/2 downgrade to HTTP/1.1 through proxy.
Protocol-level edge cases and request/response handling
test/js/bun/http/proxy-stress-protocol.test.ts
Exercises early origin replies during upload, multi-hop redirect chains, large request/response headers, 1xx informational responses, IPv6 literal targets, proxy.headers forwarding, proxy: "", HTTP/1.0 responses, redirect: "manual", redirect: "error", and response consumer APIs (text, arrayBuffer, bytes, blob, json).
Lifecycle teardown and abort stress tests
test/js/bun/http/proxy-stress-lifecycle.test.ts
Covers RST at every proxy stage (client-side, upstream-side), truncated response byte offsets, close-delimited EOF body, abort-at-each-stage polling, abort-after-headers, an ASAN-only TLS-alert subprocess probe, and in-process abort-churn stress.
Adversarial combination and WebSocket tests
test/js/bun/http/proxy-stress-adversarial.test.ts
Exercises stacked hostile CONNECT combinations, origin status matrix, header rewriting, checkServerIdentity re-entry, path/query encoding, verbose mode, AbortSignal.timeout, WebSocket tunneling (echo, large frames, RST, 407, rapid close, wss double-TLS churn), interleaved proxy/direct fetches, and CONNECT target format.
Concurrent stress, tunnel pooling, and memory probe tests
test/js/bun/http/proxy-stress-concurrent.test.ts
Validates parallel request isolation, tunnel reuse/keepalive, auth-hash separation, multi-origin pool churn, rejectUnauthorized pooling gate, 4 MB bidirectional load, idle-tunnel eviction via stray byte injection, and subprocess RSS/UAF probe using the memory fixture.

Possibly related PRs

  • oven-sh/bun#31325: Both PRs update proxy-tunnel write paths through ProxyTunnel::write and inner TLS gating to ensure request payloads traverse the encrypted tunnel rather than bypassing it during the handshake/approval phase.
  • oven-sh/bun#32462: Both PRs update src/http/lib.rs proxy-tunneling write paths (notably on_writable/RequestStage::ProxyHeaders) to prevent stalls when the outer proxy socket is closed by adding early-return guards and liveness checks.

Suggested reviewers

  • Jarred-Sumner
  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the two main changes: proxy stress tests and fixes for streamed upload and use-after-free bugs in tunnel handling.
Description check ✅ Passed The description comprehensively covers what the PR does, the bugs found/fixed, and verification steps, aligning well with the template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fetch: send streamed request body through CONNECT proxy tunnel #32636 - Fixes the same two bugs: streamed request body through CONNECT proxy tunnel never sent (has_sent_body incorrectly true for Stream bodies in ProxyHeaders), and write_to_stream_using_buffer writing plaintext to outer proxy socket instead of routing through ProxyTunnel::write

🤖 Generated with Claude Code

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Re the duplicate-detector flag for #32636: that PR is a focused fix for bug 1 (streamed body through tunnel) only, with a single regression test added to proxy.test.ts. This PR fixes both bugs and carries the full stress suite that found them. The src/http/lib.rs changes for bug 1 overlap; whichever lands first, the other rebases cleanly on top.

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Overlaps with (and supersedes) #32636, which I've closed. The has_sent_body gate and write_to_stream_using_buffer tunnel routing fixes here are identical; this PR additionally carries the on_writable UAF fix and the stress suite.

Branch farm/b7397eca/fix-proxy-tunnel-stream-body has a minimal 4-test variant of the streamed-body repro if useful for bisection.

robobun and others added 2 commits June 23, 2026 07:33
…ects, 1xx, IPv6, HTTP/1.0)

102 additional tests: early server reply (413/400/500) mid-upload
across all proxy/origin combos; 3-5 hop redirect chains mixing http and
https; 512B-16KB response header values; 1xx informational before 200
through tunnel; IPv6 literal origin; many proxy.headers entries;
proxy:'' force-direct; HTTP/1.0 origin; redirect:manual/error; response
body consumed as text/arrayBuffer/bytes/blob/json.

Also drop unused errcode import from proxy-stress-concurrent.test.ts.
Comment thread test/js/bun/http/proxy-stress-lifecycle.test.ts Outdated
Comment thread test/js/bun/http/proxy-stress-concurrent.test.ts
Drop stale design-narration comments and redundant stageHit promise
from the abort-at-each-stage block in proxy-stress-lifecycle.test.ts;
unused imports in proxy-stress-concurrent/matrix were already cleaned
by autofix.ci + the previous commit.

@coderabbitai coderabbitai 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.

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/http/lib.rs`:
- Around line 2723-2765: The proxy tunnel write path is missing socket state
validation that exists in the ProxyHeaders path. Before the first
ProxyTunnel::write call that writes from the buffer, add a guard check to return
an error if socket.is_closed() or socket.is_shutdown() is true. This prevents
inner TLS from buffering bytes indefinitely on a dead outer connection, similar
to the protection already implemented in the ProxyHeaders path. Reference the
socket validation pattern used elsewhere in the codebase to ensure consistency.
- Around line 3143-3148: The `original_request_body` match expression in the
`has_sent_body` calculation does not properly reject `Sendfile` requests before
proxy tunneling, allowing them to reach the `ProxyBody::Sendfile` handler which
unconditionally panics. Add a guard before proxy tunneling is enabled (similar
to how `can_offer_h2()` and `can_try_h3_alt_svc()` work) that returns a
recoverable error when `Sendfile` is encountered in a proxy tunnel scenario, or
modify the match expression to explicitly handle `HTTPRequestBody::Sendfile` and
reject it with an appropriate error instead of allowing it to proceed and panic.

In `@test/js/bun/http/proxy-stress-adversarial.test.ts`:
- Around line 322-331: The server is bound to the IPv4 address 127.0.0.1 on line
322, but the fetch request on line 330 uses localhost which can resolve to
either IPv4 or IPv6 depending on the system configuration, causing
inconsistency. Replace localhost in the fetch URL with 127.0.0.1 to match the
server binding address and ensure the fetch request uses the same host that the
server is listening on.

In `@test/js/bun/http/proxy-stress-concurrent.test.ts`:
- Around line 112-115: The current assertions in the proxyTls conditional block
and the HTTP-proxy rejectUnauthorized case (around lines 226-231) are too
lenient and do not enforce connection reuse properly. In the proxyTls case where
expect(proxy.connectCount()).toBeLessThanOrEqual(5) is used, tighten this
assertion to enforce actual connection reuse by reducing the maximum allowed
CONNECT count significantly below five. Similarly, in the rejectUnauthorized
test case around lines 229-231, tighten the assertion to properly enforce that
the strict tunnel is being reused rather than creating a new connection for each
request. The goal is to make these assertions fail if connection pooling
regresses.
- Around line 79-80: Remove the per-test timeout values from the test blocks in
the proxy-stress-concurrent.test.ts file. Specifically, remove the second
argument (30_000 and other timeout values) that specifies the timeout duration
for each test, as Bun already has built-in timeouts and setting explicit
per-test timeouts weakens hang detection. This applies to the test blocks at
lines 79-80, 183, 232, and 273-274 in the file, where you should remove the
timeout parameter while keeping the test name and callback function intact.
- Around line 413-414: The current assertion using toBeGreaterThanOrEqual is too
lenient and allows the test to pass even when all requests fail. Replace the
check on result.completed and result.failed with an exact equality assertion
that completed plus failed equals iterations, then add mode-specific validation
checks below it that verify the expected outcomes for each test mode (complete,
concurrent-32, redirect, and abort modes should each have distinct failure
expectations to ensure abort paths are properly exercised and other modes
maintain expected completion rates).
- Around line 333-350: The test does not actually verify that the parked tunnel
was evicted before making the second request. Before writing the stray bytes to
liveClients, capture close event promises from each client socket so you can
wait for them to complete. Assert that liveClients is not empty and that a
parked tunnel exists before attempting eviction. After writing the bytes, await
all the close promises to ensure the eviction has actually completed before
proceeding to the second fetch request. In the finally block cleanup, destroy
any remaining live sockets to ensure proper resource cleanup.

In `@test/js/bun/http/proxy-stress-errors.test.ts`:
- Around line 247-295: In the first test case, when proxyTls is true, the outer
HTTPS proxy handshake fails before sending the CONNECT request, causing the test
to pass for the wrong reason. Restrict the first test to only run with HTTP
proxies by adding a condition that skips it when proxyTls is true, or provide
separate tls configuration options for the proxy connection versus the inner
origin connection. In the second test case, the checkServerIdentity function
unconditionally rejects all hostnames including the proxy itself. Modify the
checkServerIdentity callback to conditionally return undefined (no error) when
the hostname matches the proxy, and return new Error("pinned") only when it
matches the origin hostname. Additionally, add assertions to both test cases to
verify that the proxy received at least one CONNECT request (using a method like
proxy.connectCount() or similar) to confirm the tunnel was actually attempted
before the inner TLS failure occurred.

In `@test/js/bun/http/proxy-stress-helpers.ts`:
- Around line 55-58: The restoreProxyEnv function currently sets
previously-absent proxy environment variables to empty strings, which leaves
stale empty values that can interfere with subsequent tests. Modify the function
to properly delete environment variables that were originally absent (when
saved[key] is undefined) by setting them to undefined or using delete, rather
than setting them to empty strings, so that undefined variables remain properly
deleted and do not cause cross-test pollution.
- Line 232: The upstream error handler (around line 379-383) and upstream close
handler (around line 385-391) are using socket.destroy() which closes the
connection immediately and can discard the 502 response before it's sent.
Replace the destroy() calls with client.end() to allow responses to flush
gracefully, following the same pattern already demonstrated elsewhere in the
file (see lines 250-251 and 407-412 where client.write() is followed by
client.end()). Additionally, add boolean flags to track whether the client
socket has already been closed, preventing race conditions where both the error
and close handlers might fire and attempt to close the same socket twice, which
causes the upstream refused to 502 error tests to be flaky.

In `@test/js/bun/http/proxy-stress-lifecycle.test.ts`:
- Around line 315-352: Replace the polling mechanism that checks the want
predicate on proxy.connections[0] (the poller async function using setImmediate)
with a stage callback approach. Add a non-mutating stage callback parameter to
the proxy helper that gets invoked when each proxy stage is reached, and drive
the abort from that callback by calling ac.abort() and signalStage() when the
target stage parameter is reached. This ensures the abort happens at the actual
observable proxy stage event rather than relying on polling a connection record
that may never flip to the expected state.

In `@test/js/bun/http/proxy-stress-matrix.test.ts`:
- Around line 229-232: The assertion `expect(chunks).toBeGreaterThanOrEqual(1)`
in the proxy stress matrix test is too weak because a value of 1 is already
guaranteed by the earlier check `got === payload.length`, so this doesn't
actually detect if the body was buffered into a single read as the comment
suggests it should. Change the assertion to `expect(chunks).toBeGreaterThan(1)`
to properly enforce that multiple chunks are received, which will correctly
validate that the stream is not pre-buffering the 128KB body before exposing it
to the stream consumer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e79a45c0-f6fb-48fe-9cd8-0db0d893198c

📥 Commits

Reviewing files that changed from the base of the PR and between 126e800 and 33f1675.

📒 Files selected for processing (8)
  • src/http/lib.rs
  • test/js/bun/http/proxy-stress-adversarial.test.ts
  • test/js/bun/http/proxy-stress-concurrent.test.ts
  • test/js/bun/http/proxy-stress-errors.test.ts
  • test/js/bun/http/proxy-stress-helpers.ts
  • test/js/bun/http/proxy-stress-lifecycle.test.ts
  • test/js/bun/http/proxy-stress-matrix.test.ts
  • test/js/bun/http/proxy-stress-memory-fixture.ts

Comment thread src/http/lib.rs
Comment thread src/http/lib.rs
Comment thread test/js/bun/http/proxy-stress-adversarial.test.ts Outdated
Comment thread test/js/bun/http/proxy-stress-concurrent.test.ts
Comment thread test/js/bun/http/proxy-stress-concurrent.test.ts
Comment thread test/js/bun/http/proxy-stress-errors.test.ts
Comment thread test/js/bun/http/proxy-stress-helpers.ts
Comment thread test/js/bun/http/proxy-stress-helpers.ts
Comment thread test/js/bun/http/proxy-stress-lifecycle.test.ts
Comment thread test/js/bun/http/proxy-stress-matrix.test.ts Outdated
Comment thread test/js/bun/http/proxy-stress-protocol.test.ts
Comment thread test/js/bun/http/proxy-stress-helpers.ts Outdated
Comment thread test/js/bun/http/proxy-stress-helpers.ts

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/http/proxy-stress-protocol.test.ts`:
- Around line 362-378: The test for 'proxy: "" forces direct' does not actually
set up an ambient proxy environment, so it passes regardless of whether proxy:
"" is being honored. To properly test that proxy: "" bypasses ambient proxies,
you need to configure the proxy URL created with createAdversarialProxy as the
ambient proxy in the environment before calling fetch, either by setting it as
an environment variable in an isolated subprocess context or using env isolation
to prevent race conditions with concurrent tests. Then keep the existing
assertion that proxy.connections.length equals 0 to prove the ambient proxy was
actually bypassed by the empty string proxy parameter.
- Around line 258-314: The test assertion on line 309 is too lenient and accepts
"resolved:502" as a valid outcome even though the beforeAll block (lines
262-273) has already confirmed that IPv6 loopback (::1) is available on this
host. Since ::1 is provably listenable, the proxy should successfully dial the
IPv6 origin and return a 200 response rather than failing with a 502. Tighten
the expectation in the contain() call to only accept the "200:v6" outcome, and
if the createAdversarialProxy helper is not properly handling bracketed IPv6
address format (like [::1]), normalize the IPv6 address format in that helper to
ensure correct connection handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49039992-80a3-477c-9239-ab58b5f6fd44

📥 Commits

Reviewing files that changed from the base of the PR and between 33f1675 and fa14253.

📒 Files selected for processing (2)
  • test/js/bun/http/proxy-stress-lifecycle.test.ts
  • test/js/bun/http/proxy-stress-protocol.test.ts

Comment thread test/js/bun/http/proxy-stress-protocol.test.ts Outdated
Comment thread test/js/bun/http/proxy-stress-protocol.test.ts Outdated
robobun and others added 2 commits June 23, 2026 08:06
….test.ts

src/http/lib.rs: guard write_to_stream_using_buffer's tunnel path on
socket.is_closed()/is_shutdown(), matching the ProxyHeaders arm, so
inner TLS writes don't buffer forever on a dead outer socket.

proxy-stress-helpers.ts: strip IPv6 brackets before net.connect
(getaddrinfo rejects '[::1]'); use client.end() instead of destroy()
on upstream error/close so 502 replies flush; drop stale consumer
list from the header comment.

proxy-stress-protocol.test.ts: tighten IPv6 test to assert 200 now
that the helper dials ::1 correctly; recast the proxy:'' test to
document that an empty-string proxy option is currently treated as
absent (fetch.rs get_length > 0 check), not as 'explicitly no proxy'
per the FetchTasklet comment, so ambient env is not overridden.

proxy-stress-errors.test.ts: assert connectCount() in the inner-TLS
verification tests so an outer-proxy TLS failure can't mask them.

proxy-stress-concurrent.test.ts: wait for the parked socket's close
event in the idle-eviction test; tighten memory-probe assertions to
exact iteration count + mode-specific completed/failed expectations;
assert http-proxy reject_unauthorized reuse exactly.

proxy-stress-matrix.test.ts: drop the vacuous chunks>=1 assertion.

proxy-stress-adversarial.test.ts: fetch 127.0.0.1 to match the
never-reply origin's bind address.

proxy-stress-headers.test.ts: 76 new tests covering many response
headers, duplicate Set-Cookie, Content-Type matrix, 206 Content-Range,
decompress:false, CONNECT envelope shape, long URLs, proxy switching,
and Content-Encoding after decompress.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/http/proxy-stress-headers.test.ts`:
- Around line 289-306: The test "Content-Encoding header after decompress"
verifies the body is decompressed correctly but does not verify that the
Content-Encoding header is actually removed from the response, which contradicts
the stated behavior in the test comments. Add an assertion after the existing
body check that verifies the Content-Encoding header is absent by checking that
res.headers.get("content-encoding") returns a falsy value (null or undefined),
mirroring the inverse assertion pattern used in the decompress:false test case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8785cda4-127a-44bd-bdf5-6c7cc8d25103

📥 Commits

Reviewing files that changed from the base of the PR and between fa14253 and d51264b.

📒 Files selected for processing (8)
  • src/http/lib.rs
  • test/js/bun/http/proxy-stress-adversarial.test.ts
  • test/js/bun/http/proxy-stress-concurrent.test.ts
  • test/js/bun/http/proxy-stress-errors.test.ts
  • test/js/bun/http/proxy-stress-headers.test.ts
  • test/js/bun/http/proxy-stress-helpers.ts
  • test/js/bun/http/proxy-stress-matrix.test.ts
  • test/js/bun/http/proxy-stress-protocol.test.ts
💤 Files with no reviewable changes (1)
  • test/js/bun/http/proxy-stress-matrix.test.ts

Comment thread test/js/bun/http/proxy-stress-headers.test.ts
…ress

Bun keeps the Content-Encoding header after transparent decompression
(issue #5668). The comment claimed it was stripped; correct the comment
and add the matching assertion so the tunnel path is verified to match
direct fetch.
Comment thread test/js/bun/http/proxy-stress-headers.test.ts
Comment thread test/js/bun/http/proxy-stress-headers.test.ts
…r comments

The file header listed folding, long header names, and User-Agent as
covered, but none of those are tested in this file. write_proxy_connect
does not send User-Agent on the CONNECT envelope, so drop that from the
section comment as well.
Comment thread test/js/bun/http/proxy-stress-protocol.test.ts Outdated
Comment thread test/js/bun/http/proxy-stress-helpers.ts Outdated
Comment thread test/js/bun/http/proxy-stress-errors.test.ts
Comment thread test/js/bun/http/proxy-stress-adversarial.test.ts
- proxy-stress-helpers.ts: remove unused onConnection/forceUpstream options
  and absoluteFormCount() method (leftovers from abandoned designs)
- proxy-stress-protocol.ts: gate IPv6 tests with describe.skipIf(!isIPv6())
  instead of a bare early return, so hosts without ::1 report SKIPPED
- proxy-stress-errors.ts: make the h2-not-offered test observe the actual
  inner-TLS ALPN result via a raw tls server that echoes socket.alpnProtocol
- proxy-stress-adversarial.ts: tighten interleaved proxy/direct assertions
  (bytesUp unchanged across direct requests; http-proxy connectCount === 1)
Comment thread test/js/bun/http/proxy-stress-concurrent.test.ts Outdated
…ertion

The separate { mode, growth } toEqual with expect.any(Number) could never
fail. Fold mode + the rounded ratio into the threshold check via a
withinBound:true key so they appear in the diff when the leak bound trips.
Comment thread test/js/bun/http/proxy-stress-adversarial.test.ts
Comment thread test/js/bun/http/proxy-stress-lifecycle.test.ts Outdated
robobun added 2 commits June 23, 2026 10:07
… stageReached

- proxy-stress-adversarial.ts: the large binary frame round-trip test now
  rejects on a clean close before the echo arrives, matching the adjacent
  echo test. A clean server close fires onclose (not onerror), so without
  this the test hung to framework timeout on regression.
- proxy-stress-lifecycle.ts: remove the write-only stageReached flag and
  its tautological assertion. await poller resolving is itself proof the
  stage was reached.
Comment thread src/http/lib.rs Outdated
Comment thread test/js/bun/http/proxy-stress-helpers.ts
…m's handler

SSLWrapper::write_data fires trigger_close_callback before returning a
fatal Err, which runs proxy_tunnel::on_close -> close_and_fail and can
synchronously free the HTTPClient via the result callback. Propagating
that Err to write_to_stream's Err arm then dereferences freed memory.

Match the pre-existing ProxyTunnel::write callers in on_writable: on a
non-WantRead/WantWrite Err, return Ok(true) so write_to_stream only
releases the independently-allocated stream buffer and returns without
touching self.

Also guard createAdversarialProxy's onData against re-entry between head
parse and upstream connect, which could double-push a connection record
and orphan the first dial when an absolute-form client sends body bytes
in a separate packet before loopback connect completes.
Comment thread test/js/bun/http/proxy-stress-adversarial.test.ts Outdated
…ally

Matches the cleanup pattern used by every other inline-server test in
the suite (duplicate Set-Cookie, h2 ALPN, 1xx/early-reply/HTTP1.0
origins). Moves the proxy creation inside the try so a failure there
does not leak the listening origin.

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

All prior review feedback has been addressed and I found nothing new this round, but the src/http/lib.rs changes touch UAF-prone proxy-tunnel lifecycle paths (three separate liveness/Err-propagation fixes around ProxyTunnel::write / SSLWrapper callbacks) that warrant a human sign-off.

Extended reasoning...

Overview

This PR adds a 661-test proxy stress suite (9 new files under test/js/bun/http/, ~3,500 lines) and fixes three bugs in src/http/lib.rs (~100 lines): (1) streamed request bodies through a CONNECT tunnel were never sent because has_sent_body was computed from the empty Stream buffer and write_to_stream_using_buffer wrote plaintext to the outer socket instead of routing through ProxyTunnel::write; (2) a heap-use-after-free in on_writable when a TLS alert is buffered with the inner handshake flight (the socket.is_closed() guard after proxy.on_writable()); (3) a UAF found during this review where the new tunnel branch's fatal Err from ProxyTunnel::write propagated to write_to_stream's handler after trigger_close_callback had already freed *self — fixed by returning Ok(true) to match the sibling callers' pattern.

Security risks

None introduced. The changes are defensive (liveness guards, routing plaintext through the inner TLS instead of the outer socket). The test suite only spins up loopback servers with self-signed certs and does not touch auth, permissions, or untrusted input parsing in production code.

Level of scrutiny

High for the src/http/lib.rs changes. This is production HTTP-client code on the proxy-tunnel write path, dealing with raw pointer dereferencing (proxy_tunnel::raw_as_mut), SSL wrapper callback ordering, and the exact UAF class this PR is fixing — one of the three fixes was surfaced during review, which confirms the area is subtle. The fixes look correct and well-commented, mirror established patterns elsewhere in the file (the let Ok(...) else { return; } idiom at the other two ProxyTunnel::write call sites), and are backed by deterministic ASAN repros, but memory-safety changes of this kind should have a human reviewer verify the lifecycle reasoning.

The test files are lower risk (new coverage only, no behavior changes to existing tests) but are large enough that flakiness on non-Linux CI lanes is worth watching once they land.

Other factors

This PR has been through extensive iterative review: ~12 inline findings from me and ~14 from CodeRabbit, every one addressed or declined with a clear rationale by the author. The UAF guard at line 2918 was independently reproduced on a separate branch. The duplicate PR #32636 (bug 1 only) was closed in favor of this one. CI showed musl build failures on commit 68fa164 (build-bun, not test failures); the most recent commit 440b4de is test-only, so those may be infra-related — worth confirming the latest CI run is green before merge. The Sendfile-through-proxy panic flagged by CodeRabbit is pre-existing on main and intentionally out of scope here.

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI status

The diff is green on every proxy-related test across all lanes. Two consecutive builds each had a single unrelated failure on the same darwin-aarch64-14 test lane:

build failing test subsystem
64196 (8ad1ad9) test/js/third_party/grpc-js/test-server.test.ts SIGTRAP node:http2 server
64216 (440b4de) test/js/node/test/parallel/test-tls-client-destroy-soon.js node:tls client (48KB short read on destroySoon)

Neither test uses fetch() or the HTTP client proxy path. The src/http/lib.rs changes in this PR are all gated on self.proxy_tunnel.is_some() / RequestStage::ProxyHeaders, which node:tls and node:http2 never reach. All seven new proxy-stress-*.test.ts files (839 tests) pass on the failing lane in both builds.

All 32 review threads resolved; claude[bot]'s final review found nothing new and recommends human sign-off for the src/http/lib.rs lifecycle changes. Ready for maintainer review.

Comment thread src/http/lib.rs Outdated
Comment on lines +2715 to +2781
// When tunneling through a proxy the stream body must go through
// the inner TLS session (ProxyTunnel::write), not the outer socket:
// writing plaintext to `socket` here would interleave it with the
// tunnel's TLS records and corrupt the stream at the origin.
// WantRead/WantWrite from the inner SSL are treated as backpressure
// so the next onWritable retries the flush. Encrypted bytes reach
// the outer socket via the SSLWrapper's write_encrypted callback,
// same as the ProxyHeaders/ProxyBody::Bytes paths.
if let Some(proxy_ptr) = self.proxy_tunnel.as_ref().map(|p| p.as_ptr()) {
// Same guard as the ProxyHeaders arm: inner TLS writes succeed
// at the SSL layer and buffer on a dead outer socket forever.
if socket.is_closed() || socket.is_shutdown() {
return Err(err!(ConnectionClosed));
}
let proxy = proxy_tunnel::raw_as_mut(proxy_ptr);
let to_send_len = buffer.slice().len();
if to_send_len > 0 {
match ProxyTunnel::write(proxy, buffer.slice()) {
Ok(amount) => {
self.state.request_sent_len += amount;
buffer.cursor += amount;
if amount < to_send_len {
if !data.is_empty() {
let _ = buffer.write(data);
}
return Ok(true);
}
if buffer.is_empty() {
buffer.reset();
}
}
Err(e) if e == err!(WantRead) || e == err!(WantWrite) => {
if !data.is_empty() {
let _ = buffer.write(data);
}
return Ok(true);
}
// A fatal SSL_write error fires trigger_close_callback
// before returning, which runs proxy on_close →
// close_and_fail and may have freed *self via the
// result callback. Match the other ProxyTunnel::write
// callers: bail without touching self. Ok(true) makes
// write_to_stream only release the (independently
// allocated) stream buffer and return.
Err(_) => return Ok(true),
}
}
if !data.is_empty() {
match ProxyTunnel::write(proxy, data) {
Ok(sent) => {
self.state.request_sent_len += sent;
if sent < data.len() {
let _ = buffer.write(&data[sent..]);
return Ok(true);
}
}
Err(e) if e == err!(WantRead) || e == err!(WantWrite) => {
let _ = buffer.write(data);
return Ok(true);
}
// See the matching arm above: on_close already ran.
Err(_) => return Ok(true),
}
}
return Ok(false);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can you simplify this code? It's very repetitive. And the comment is mostly unnecessary.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Simplified in 554db3e. Collapsed both match blocks into let Ok(n) = ... else { ...; return Ok(true); } (all Err variants end up as backpressure anyway), dropped the redundant is_empty() guards, and cut the comments down to two short blocks. Net: 67 lines → 39.

Collapse the two match blocks into let-else form. All Err variants
(WantRead, WantWrite, fatal) return Ok(true) as backpressure, so the
distinction is unnecessary at this level. Trim the comments to the
one line that matters for safety.
@Jarred-Sumner Jarred-Sumner merged commit d34ca02 into main Jun 23, 2026
64 of 77 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/743ae77b/proxy-stress-tests branch June 23, 2026 22:35
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