Skip to content

http: fix ProxyTunnel use-after-free when response completes mid-handleReading#30606

Closed
robobun wants to merge 5 commits into
mainfrom
farm/4a7d997b/proxy-tunnel-stale-ctx-uaf
Closed

http: fix ProxyTunnel use-after-free when response completes mid-handleReading#30606
robobun wants to merge 5 commits into
mainfrom
farm/4a7d997b/proxy-tunnel-stale-ctx-uaf

Conversation

@robobun

@robobun robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator

What

The SSLWrapper backing a ProxyTunnel stored a raw *HTTPClient as its handlers.ctx. When a proxied HTTPS response completed inside handleReading's triggerDataCallback, the result callback freed the ThreadlocalAsyncHTTP (and the embedded HTTPClient) synchronously. If the same handleReading invocation then processed an SSL close/error, triggerCloseCallback would invoke ProxyTunnel.onClose with the now-freed pointer.

AddressSanitizer: use-after-poison
  #0 http.ProxyTunnel.onClose (ProxyTunnel.zig:231)
  #1 SSLWrapper(*http.http).triggerCloseCallback (ssl_wrapper.zig:320)
  #2 SSLWrapper(*http.http).handleReading (ssl_wrapper.zig:453)
  #3 SSLWrapper(*http.http).handleTraffic (ssl_wrapper.zig:518)
  #4 SSLWrapper(*http.http).receiveData (ssl_wrapper.zig:240)
  #5 http.ProxyTunnel.receive (ProxyTunnel.zig:360)
  #6 http.http.onData (http.zig:2045)

The keepalive path (detachOwner) intentionally left wrapper.handlers.ctx stale on the assumption that no callbacks would fire while pooled — but that assumption is violated when the detach happens inside an in-flight handleReading call.

Fix

Make the wrapper's ctx the *ProxyTunnel itself — it's refcounted and held alive across receive()/onWritable(), so it's always valid while callbacks can fire. Track the owning HTTPClient in a new owner: ?*HTTPClient field that is set in start()/adopt() and cleared in detachOwner()/detachSocket(). Each callback now does const this = tunnel.owner orelse return; before touching the client.

Found by Fuzzilli (fingerprint f07c6abbb250e2e1).

When a proxied HTTPS response completes inside SSLWrapper.handleReading's
triggerDataCallback, the HTTPClient (embedded in ThreadlocalAsyncHTTP) is
freed synchronously by onAsyncHTTPCallback. If the same handleReading call
then hits an SSL error/close_notify, triggerCloseCallback fires onClose
with the now-freed *HTTPClient stored in handlers.ctx.

Point handlers.ctx at the refcounted ProxyTunnel instead (kept alive across
receive()/onWritable() via ref/deref) and look up the owning client through
a nullable owner field that detachOwner()/detachSocket() clear. Callbacks
early-return when there is no owner, so a pooled or detaching tunnel never
dereferences freed memory.
@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:56 AM PT - May 13th, 2026

@robobun, your commit 80f7b5d has 2 failures in Build #53994 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30606

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

bun-30606 --bun

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0cec451f-bed2-42ce-888b-678df47fe570

📥 Commits

Reviewing files that changed from the base of the PR and between 869fe68 and 80f7b5d.

📒 Files selected for processing (1)
  • src/http/HTTPContext.zig

Walkthrough

ProxyTunnel callbacks now receive the tunnel and derive the HTTPClient from a nullable owner field cleared on detach to prevent stale-callback use-after-free. Lifecycle init, onWritable/tunnel liveness checks, and pooled-socket deinit docs updated. A regression fixture and test reproduce and assert the behavior under concurrent proxied HTTPS requests.

Changes

ProxyTunnel use-after-free safety

Layer / File(s) Summary
ProxyTunnel callback safety refactoring
src/http/ProxyTunnel.zig
ProxyTunnel adds an owner: ?*HTTPClient. Callbacks (onOpen, onData, onHandshake, onClose) are refactored to receive *ProxyTunnel and derive the client from tunnel.owner. Public writeEncrypted signature updated to accept tunnel and lookup owner.
ProxyTunnel initialization and owner lifecycle
src/http/ProxyTunnel.zig
start() initializes the SSL wrapper with the tunnel as context and sets owner to the current HTTPClient. detachSocket() and detachOwner() clear the owner field. adopt() stops updating wrapper.ctx and sets owner to reattach the tunnel to a new HTTPClient.
onWritable retention check
src/http/http.zig
onWritable temporarily ref()s the ProxyTunnel before calling proxy.onWritable(...), deref()s after, and returns early if proxy.owner != this indicating detachment during the call.
Tunnel poolability liveness check
src/http/http.zig
Keep-alive socket release logic tightened: wrapper must not be shutdown and must not have flags.fatal_error to be considered poolable; pooled socket selection skips and terminates entries with missing/shutdown/fatal wrappers.
HTTPContext pooled socket cleanup documentation
src/http/HTTPContext.zig
Comment in Context.deinit() updated to state shutdown is unnecessary: http_socket.close(.failure) force-closes TCP and the close callback will no-op once owner is cleared by detachOwner().
ProxyTunnel use-after-free regression fixture
test/js/web/fetch/fetch-proxy-tunnel-onclose-uaf-fixture.ts
Adds a fixture that runs a TLS backend and a CONNECT proxy which injects a malformed TLS record after client→upstream writes to force SSL decoding and a stale-callback close; runs concurrent proxied fetches and logs { ok, err }.
ProxyTunnel use-after-free regression test
test/js/web/fetch/fetch-proxy-tunnel-onclose-uaf.test.ts
Spawns the fixture with TLS env, forces proxy usage, collects output, asserts successful exit and ok > 0, bounded by a 30s timeout.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: addressing a ProxyTunnel use-after-free bug triggered when response handling completes during the reading phase.
Description check ✅ Passed The description provides a comprehensive explanation of the problem (SSLWrapper storing raw HTTPClient pointer leading to UAF), includes a stack trace, explains the root cause, describes the fix strategy, and cites the discovery source.
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 and usage tips.

@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/web/fetch/fetch-proxy-tunnel-onclose-uaf.test.ts`:
- Around line 93-103: The test currently asserts exitCode before validating
stdout; change the order so you first await and parse stdout/stderr (use
proc.stdout.text(), proc.stderr.text(), proc.exited as before), then parse
stdout into lines and assert result.ok (the existing JSON parse and
expect(result.ok).toBeGreaterThan(0)) before asserting exitCode; finally, if
exitCode !== 0 log the stderr and then expect(exitCode).toBe(0). Update the
block that references stdout, stderr, exitCode, lines, and result to perform
stdout assertions first and exitCode assertion last.
🪄 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: 346d71fc-f2fd-4d48-89af-4ed9bb21bf3f

📥 Commits

Reviewing files that changed from the base of the PR and between 3bf4b33 and 20612b5.

📒 Files selected for processing (3)
  • src/http/HTTPContext.zig
  • src/http/ProxyTunnel.zig
  • test/js/web/fetch/fetch-proxy-tunnel-onclose-uaf.test.ts

Comment on lines +93 to +103
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

if (exitCode !== 0) {
console.error("Fixture stderr:", stderr);
}
expect(exitCode).toBe(0);

const lines = stdout.trim().split("\n");
const result = JSON.parse(lines[lines.length - 1]);
expect(result.ok).toBeGreaterThan(0);
} finally {

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reorder subprocess assertions so exit code is asserted last.

On Line 98, exit code is asserted before stdout expectations. Move the stdout parsing/assertion first, then assert exit code last.

Suggested patch
-    if (exitCode !== 0) {
-      console.error("Fixture stderr:", stderr);
-    }
-    expect(exitCode).toBe(0);
-
     const lines = stdout.trim().split("\n");
     const result = JSON.parse(lines[lines.length - 1]);
     expect(result.ok).toBeGreaterThan(0);
+    if (exitCode !== 0) {
+      console.error("Fixture stderr:", stderr);
+    }
+    expect(exitCode).toBe(0);

As per coding guidelines, when spawning processes in tests, check stdout expectations before exit code expectations to provide more useful error messages on test failure.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
console.error("Fixture stderr:", stderr);
}
expect(exitCode).toBe(0);
const lines = stdout.trim().split("\n");
const result = JSON.parse(lines[lines.length - 1]);
expect(result.ok).toBeGreaterThan(0);
} finally {
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const lines = stdout.trim().split("\n");
const result = JSON.parse(lines[lines.length - 1]);
expect(result.ok).toBeGreaterThan(0);
if (exitCode !== 0) {
console.error("Fixture stderr:", stderr);
}
expect(exitCode).toBe(0);
} finally {
🤖 Prompt for 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.

In `@test/js/web/fetch/fetch-proxy-tunnel-onclose-uaf.test.ts` around lines 93 -
103, The test currently asserts exitCode before validating stdout; change the
order so you first await and parse stdout/stderr (use proc.stdout.text(),
proc.stderr.text(), proc.exited as before), then parse stdout into lines and
assert result.ok (the existing JSON parse and
expect(result.ok).toBeGreaterThan(0)) before asserting exitCode; finally, if
exitCode !== 0 log the stderr and then expect(exitCode).toBe(0). Update the
block that references stdout, stderr, exitCode, lines, and result to perform
stdout assertions first and exitCode assertion last.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun fetch + HTTPS-over-CONNECT-proxy: raw upstream HTTP/1.1 leaks into response.body #30381 - HTTPS-over-CONNECT-proxy response corruption: the reporter traces the bug to handleResponseMetadata being invoked twice with the CONNECT envelope, which is consistent with the stale ctx pointer this PR fixes in ProxyTunnel

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #30381

🤖 Generated with Claude Code

Comment on lines +95 to +102
if (exitCode !== 0) {
console.error("Fixture stderr:", stderr);
}
expect(exitCode).toBe(0);

const lines = stdout.trim().split("\n");
const result = JSON.parse(lines[lines.length - 1]);
expect(result.ok).toBeGreaterThan(0);

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.

🟡 Per CLAUDE.md (line 128), tests that spawn processes should assert on stdout before exitCode so failures surface useful output. Here expect(exitCode).toBe(0) runs before stdout is parsed/asserted, so if the subprocess crashes you only see "expected 0, received 134" without stdout. Consider asserting on stdout (or at least logging it in the failure block) before the exitCode check.

Extended reasoning...

What the issue is

The repo's testing conventions explicitly state in CLAUDE.md:128:

When spawning processes, tests should expect(stdout).toBe(...) BEFORE expect(exitCode).toBe(0). This gives you a more useful error message on test failure.

And test/CLAUDE.md shows the canonical ordering: expect(stderr).toBe(""); expect(stdout).toBe(...); expect(exitCode).toBe(0);.

In test/js/web/fetch/fetch-proxy-tunnel-onclose-uaf.test.ts:95-102, the new test does the opposite:

if (exitCode !== 0) {
  console.error("Fixture stderr:", stderr);
}
expect(exitCode).toBe(0);

const lines = stdout.trim().split("\n");
const result = JSON.parse(lines[lines.length - 1]);
expect(result.ok).toBeGreaterThan(0);

The exitCode assertion comes first; the stdout parse/assert comes after.

How it manifests / why it matters

This test is specifically guarding against a UAF crash. If a regression reintroduces the bug:

  1. The subprocess aborts (e.g. ASAN SIGABRT, exit code 134).
  2. The test enters the if (exitCode !== 0) block and logs stderr — good, the ASAN report likely lives there.
  3. expect(exitCode).toBe(0) fails and the test stops.
  4. stdout is never logged or asserted. Anything the fixture wrote to stdout before crashing (e.g. partial JSON, debug output, the {ok: N} line if the crash happened after console.log) is silently discarded.

So while the stderr log provides partial mitigation, stdout is dropped on the floor, and the documented project convention is violated.

Step-by-step proof

  • Suppose the fixture completes 3 of 5 rounds, prints something to stdout, then hits the UAF and aborts with exit code 134.
  • exitCode !== 0console.error("Fixture stderr:", stderr) runs (stderr shown).
  • expect(exitCode).toBe(0) throws → test fails with "expected 0, received 134".
  • Lines 100-102 never execute → stdout is never inspected or printed.
  • CI output: just the exit-code mismatch + stderr; no stdout.

Compare with the convention-compliant ordering: asserting on stdout first would surface the actual fixture output in the failure message (or, if stdout were also logged in the error block, it would be visible alongside stderr).

Why existing code doesn't prevent it

The console.error("Fixture stderr:", stderr) guard only covers stderr. There is no equivalent for stdout, and the early expect(exitCode) short-circuits before the stdout assertions on lines 100-102 can run.

Fix

Either reorder to match the convention:

const lines = stdout.trim().split("\n");
const result = JSON.parse(lines[lines.length - 1] || "{}");
expect(result.ok).toBeGreaterThan(0);
expect(exitCode).toBe(0);

…or, more minimally, also log stdout in the failure block:

if (exitCode !== 0) {
  console.error("Fixture stderr:", stderr);
  console.error("Fixture stdout:", stdout);
}

This is a test-convention nit — it doesn't affect the correctness of the test's pass/fail behavior, only the diagnosability of failures.

Replace the best-effort proxy/keepalive load test with a fixture that
appends a malformed TLS record immediately after the HTTP-response
record on the proxied connection. SSLWrapper.handleReading then sees
app-data + SSL_ERROR_SSL in one call: triggerDataCallback completes the
request (freeing the HTTPClient via the keepalive pool path, since
received_ssl_shutdown stays false), and triggerCloseCallback fires on
the freed pointer. On debug+ASAN this aborts with use-after-poison at
ProxyTunnel.onClose pre-fix and exits cleanly with the owner-field fix.

Also guard HTTPClient.onWritable: proxy.onWritable()'s deferred flush()
can drive the same completion path, after which reading
this.state.request_stage is a UAF. Check proxy.owner after the call and
bail if the tunnel detached.
Comment thread src/http/ProxyTunnel.zig
robobun added 2 commits May 13, 2026 04:54
When SSL_read returns SSL_ERROR_SSL/SYSCALL in the same handleReading()
that delivers a keepalive response, flags.fatal_error is set before
triggerDataCallback but isShutdown() (used by tunnel_poolable) doesn't
look at it — so the dead wrapper was pooled and the ownerless onClose
silently returned. A subsequent adopt() would then try to write through
a closed_notified wrapper and swallow error.ConnectionClosed, hanging
until timeout. Check fatal_error alongside isShutdown().
Comment thread src/http/ProxyTunnel.zig
const lastLine = stdout.trim().split("\n").pop()!;
const result = JSON.parse(lastLine);
expect(result.ok).toBeGreaterThan(0);
}, 30_000);

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.

🟡 nit: test/CLAUDE.md says "Do not set a timeout on tests. Bun already has timeouts." — though given this fixture does 4×32 proxied TLS handshakes the 30s is probably load-bearing on slow CI, so if you want to drop it consider trimming the round/batch counts instead.

Extended reasoning...

What this is

test/CLAUDE.md:120 states:

CRITICAL: Do not set a timeout on tests. Bun already has timeouts.

The new test passes 30_000 as the second argument to test() at test/js/web/fetch/fetch-proxy-tunnel-onclose-uaf.test.ts:45, which is an explicit per-test timeout. That's a documented-convention violation, not a functional bug — the test passes/fails identically either way.

Step-by-step

  1. test/CLAUDE.md:120 documents the rule.
  2. Line 45 of the new file ends the test("...", async () => { ... }, 30_000); call with an explicit 30-second timeout literal.
  3. There is no other mechanism in this file (no jest.setTimeout, no harness flag) that would supply a timeout, so the 30_000 is the only thing overriding Bun's default.

Why the rule isn't a clean fit here

The fixture spawns a subprocess that performs 4 rounds × 32 = 128 proxied HTTPS requests. Each one is TCP connect → CONNECT → inner TLS handshake → request/response. On slow CI runners (Windows in particular) that can comfortably exceed Bun's default 5s test timeout, so simply deleting 30_000 would likely make the test flaky — i.e. the "fix" is worse than the violation.

This is also far from unique in the codebase: a grep shows 150+ test files passing a numeric second argument to test(), including the immediately-neighbouring fetch-leak.test.ts, fetch-proxy-tls-intern-race.test.ts, and fetch-redirect.test.ts. So enforcement of this rule is already inconsistent and the new file matches the local pattern rather than diverging from it.

Why flag it anyway

It's new code being added, the guideline is explicitly marked CRITICAL in the repo's own docs, and there's a straightforward alternative that satisfies both concerns: the iteration counts in the fixture are arbitrary load-generators, not correctness-bearing constants.

Suggested fix

Either leave the timeout (matching neighbours) and ignore this nit, or — if you'd prefer to follow test/CLAUDE.md — drop the 30_000 and reduce the fixture's workload so it fits the default timeout, e.g.:

for (let round = 0; round < 2; round++) {
  for (let i = 0; i < 16; i++) {

32 concurrent + 2 rounds is still plenty to trip the UAF under ASAN (the original repro is a single-request race; the batching just amortises scheduling jitter).

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.

Leaving the 30s timeout — matches the neighbouring fetch-proxy-tls-intern-race.test.ts and is load-bearing for 128 proxied TLS handshakes on slow CI. Trimming the batch count risks losing determinism on the UAF repro.

tunnel_poolable is a snapshot taken inside triggerDataCallback; a
close_notify or SSL error arriving later in the same handleReading()
(e.g. the buffer-fill flush at ssl_wrapper.zig:468 for a >=64KB body)
lands after detachOwner() and the ownerless onClose no-ops, leaving a
closed_notified/fatal_error wrapper in the pool. existingSocket() only
validated the outer TCP socket, so the next matching request would
adopt() a dead inner TLS session and hang on a swallowed
error.ConnectionClosed. Check the wrapper's isShutdown()/fatal_error
before handing a pooled tunnel back.
@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

This crash signature (ProxyTunnel onClose UAF via handle_reading's trigger_close_callback after the response completes mid-read) is still present on current main in the shipping Rust implementation. The files this PR patches (src/http/ProxyTunnel.zig, src/http/http.zig, src/http/HTTPContext.zig) are the Zig porting references and are no longer compiled, so this change does not affect the built binary.

#31959 fixes it in the Rust path (src/uws/lib.rs SSLWrapper::shutdown marks the wrapper closed_notified on the already-shut-down fast-shutdown early return) with a deterministic single-shot ASAN repro. This PR can probably be closed once that lands.

cirospaciari added a commit that referenced this pull request Jun 17, 2026
…letes mid-read (#31959) [publish images]

Fixes a use-after-free in the HTTP client's proxy tunnel close path
(Sentry BUN-2VY8, ~10 events/day on Windows release builds; reproduces
deterministically under ASAN on all platforms).

## Repro

`fetch()` through an HTTP CONNECT proxy to an HTTPS origin, where the
origin's final response bytes and its TLS `close_notify` reach the
client in a single TCP batch (origin writes the response and immediately
closes). The regression test builds exactly that: a local CONNECT proxy
that holds origin-to-client bytes after the handshake and flushes
session tickets + response + close_notify in one write.

On an unfixed ASAN build:

```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 8 thread T11 (HTTP Client)
  #0 Option<RefPtr<ProxyTunnel>>::as_ref
  #1 bun_http::proxy_tunnel::on_close                    src/http/ProxyTunnel.rs:525
  #2 SSLWrapper<*mut HTTPClient>::trigger_close_callback src/uws/lib.rs:802
  #3 SSLWrapper<*mut HTTPClient>::handle_reading         src/uws/lib.rs:1022
  #4 SSLWrapper<*mut HTTPClient>::handle_traffic
  #5 SSLWrapper<*mut HTTPClient>::receive_data
  #6 ProxyTunnel::receive                                src/http/ProxyTunnel.rs:751
freed by:
  AsyncHTTP::on_async_http_callback_raw                  src/http/AsyncHTTP.rs:813
  HTTPClient::send_progress_update_without_stage_check   src/http/lib.rs:3793
```

## Cause

1. `handle_reading` processes the batch: `SSL_read` returns the body
bytes, the next `SSL_read` hits `close_notify`
(`SSL_ERROR_ZERO_RETURN`), which sets `received_ssl_shutdown` and
`sent_ssl_shutdown` before flushing the already-decrypted bytes through
the data callback.
2. The data callback completes the response. The done path runs
`close_proxy_tunnel(true)` -> `ProxyTunnel::shutdown()` ->
`SSLWrapper::shutdown(true)`, which hits the already-shut-down early
return (`sent_ssl_shutdown || fatal_error`) and returns **without
setting `closed_notified`**. The result callback then frees the
`ThreadlocalAsyncHTTP` embedding the `HTTPClient`, the exact pointer
stored in the wrapper's `handlers.ctx`.
3. Control returns to `handle_reading`. Its liveness guard
(`ssl.is_none() || closed_notified()`) passes because neither is set, so
`trigger_close_callback()` invokes `on_close(handlers.ctx)` on the freed
client. When the allocation has been recycled, `on_close` can ref or
close a different request's tunnel instead of faulting.

## Fix

`src/uws/lib.rs`: when `SSLWrapper::shutdown(fast_shutdown=true)` takes
the already-shut-down early return, fire `trigger_close_callback()`
(idempotent via `closed_notified`) so the wrapper is marked closed
before the owner detaches and frees `handlers.ctx`. A fast shutdown is a
full teardown, and the normal fast-shutdown path already fires the close
callback unconditionally; this only closes the gap where the SSL-level
shutdown had already happened.

Graceful `shutdown(false)` (node:tls half-close via UpgradedDuplex /
WindowsNamedPipe) is unchanged, so reads after a sent `close_notify`
keep working.

## Verification

New test in `test/js/bun/http/proxy.test.ts` (`test.skipIf(!isASAN)`,
the UAF is only deterministic under ASAN): fails on an unfixed ASAN
debug build with the heap-use-after-free above, passes with the fix.
Full `proxy.test.ts` (46 tests) plus `node-tls-connect`,
`node-tls-upgrade`, `node-tls-duplex-close-throw-uaf`,
`node-tls-socket-allow-half-open-option`, `node-tls-server`,
`fetch-tls-cert`, and `node-https-checkServerIdentity` suites pass.

## Note on the asan-lane CI failure (#32144)

The intermittent LeakSanitizer failure on the x64-asan shard (deferred
napi finalizers parked on a never-drained cleanup-hook list at `bun
test` exit) is being fixed in #32146, which carries the same
`global_exit()` drain plus a hooks-only guard that skips pending
`napi_wrap` finalizers on undrained-loop exits. A subset version of that
fix was briefly on this branch (e59bc1d) but without the hooks-only
guard it made `test/js/third_party/duckdb/duckdb-basic-usage.test.ts`
SEGV at exit on the asan lane (build 62135), exactly the failure mode
#32146's guard prevents, so it was reverted (61f9e70). This PR is
scoped to the proxy-tunnel UAF; its asan lane can still intermittently
hit the pre-existing #32144 leak until #32146 lands.

## Related PRs

- #30606 addresses the same crash signature but patches only the `.zig`
reference files, which are no longer compiled; this PR fixes the
shipping Rust implementation.
- #31952 fixes the same UAF by calling a new `mark_close_notified()`
helper from `ProxyTunnel::shutdown` (silently setting the flag at one
call site, with `close_raw` exempted). This PR instead closes the gap
inside `SSLWrapper::shutdown(true)` itself, so every fast-shutdown
caller (`ProxyTunnel::shutdown`, `ProxyTunnel::close_raw`,
`UpgradedDuplex::close`, `WebSocketProxyTunnel::shutdown`) gets the same
"no callbacks after teardown" guarantee without new wrapper API or a
shutdown/close_raw asymmetry. The close callback is fired rather than
suppressed, so the error teardown path keeps delivering `on_close` ->
`close_and_fail` exactly once (idempotent via `closed_notified`). Test
here is a deterministic single-shot repro (the test proxy reassembles
TLS records and flushes tickets + response + close_notify in one write)
rather than an iteration loop.

---------

Co-authored-by: Ciro Spaciari MacBook <ciro@anthropic.com>
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this fix targets .zig source files, which are no longer compiled now that Bun's runtime has been ported to Rust. The corresponding Rust implementation should be checked separately if the underlying bug still reproduces.

@robobun robobun closed this Jun 23, 2026
Jarred-Sumner pushed a commit that referenced this pull request Jun 23, 2026
…AF through tunnel (#32635)

## 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_stream` → `write_to_stream_using_buffer` →
`write_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_fail` → `terminate_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

- #31959 fixed a sibling UAF on the shutdown side of the same
`SSLWrapper` callback chain.
- #30606 touches the same `ProxyTunnel` close path but only in the
`.zig` reference files.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.

1 participant