Skip to content

http: fix use-after-free in proxy-tunnel close dispatch - #31952

Closed
EffortlessSteven wants to merge 1 commit into
oven-sh:mainfrom
EffortlessSteven:claude/proxytunnel-onclose-uaf-fix
Closed

http: fix use-after-free in proxy-tunnel close dispatch#31952
EffortlessSteven wants to merge 1 commit into
oven-sh:mainfrom
EffortlessSteven:claude/proxytunnel-onclose-uaf-fix

Conversation

@EffortlessSteven

Copy link
Copy Markdown
Contributor

What this does

fetch() through an HTTP CONNECT proxy to https:// heap-use-after-frees when a response's final bytes and the TLS close_notify land in one read: the response completes, freeing the HTTPClient, then a same-dispatch close callback reads it.

Fix: the proxy completion teardown marks the connection close-notified, so the pending close callback self-bails. The error teardown and SSLWrapper::shutdown are unchanged.

Verification

Red/green: unpatched aborts (ASan UAF), patched completes.

Check Result
body + close_notify in one read unpatched abort, patched ok
malformed response coalesced with close rejects, not swallowed
proxy / TLS / duplex / ws suites no regression

Review map

  • ProxyTunnel.rs: ProxyTunnel::shutdown marks close-notified; close_raw untouched
  • uws/lib.rs: mark_close_notified helper
  • *-close-uaf.test.ts: asserts tunnel traversal + exact split

A heap-use-after-free in ProxyTunnel::on_close (src/http/ProxyTunnel.rs): when a
proxied-TLS response completes inside the data callback that
SSLWrapper::handle_reading delivers right before its close callback, the
completion frees the HTTPClient, then the close callback in the same read
dispatch dereferences the freed client. handle_reading's guard at uws:1017 only
checks SSLWrapper state, not client liveness.

The completion teardown (close_proxy_tunnel -> ProxyTunnel::shutdown) now marks
the connection close-notified, so the pending handle_reading close callback
self-bails instead of running on_close on the freed client. The error teardown
(close_raw) is untouched: it still relies on on_close -> close_and_fail to
deliver the error, so its close callback fires as before. SSLWrapper::shutdown
is unchanged, so other shutdown(true) callers are unaffected.

Triggers when a proxied https response's final bytes and TLS close_notify arrive
in one read (SSL_read returns body, then ZERO_RETURN, in one handle_reading).

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@coderabbitai

coderabbitai Bot commented Jun 7, 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: f4c6b09a-5594-4184-af38-9e35589f8dba

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and c643601.

📒 Files selected for processing (4)
  • src/http/ProxyTunnel.rs
  • src/uws/lib.rs
  • test/js/web/fetch/fetch-proxy-tunnel-close-uaf-fixture.ts
  • test/js/web/fetch/fetch-proxy-tunnel-close-uaf.test.ts

Walkthrough

This PR fixes a heap-use-after-free bug in proxied TLS handling by marking close notification in SSLWrapper before processing final responses. It prevents the on_close callback from executing on freed client memory when TLS close_notify and response body arrive in the same read, and validates the fix with a regression test fixture and harness.

Changes

Proxied TLS UAF Fix

Layer / File(s) Summary
Mark close-notified in SSL wrapper and shutdown path
src/uws/lib.rs, src/http/ProxyTunnel.rs
SSLWrapper gains a public mark_close_notified() method that sets closed_notified without invoking on_close. ProxyTunnel::shutdown calls this method to suppress the close callback when TLS response and close_notify coalesce into a single read.
Test fixture for coalesced TLS response + close scenario
test/js/web/fetch/fetch-proxy-tunnel-close-uaf-fixture.ts
Standalone executable fixture that reproduces the UAF scenario with a loopback TLS upstream server coalescing response body and close_notify, forwarding through a TCP CONNECT proxy, and measuring fetch request outcomes across iterations.
Regression test harness and assertions
test/js/web/fetch/fetch-proxy-tunnel-close-uaf.test.ts
Test harness spawns the fixture subprocess with controlled environment and TLS material, captures output, and asserts no ASAN/UAF indicators and expected deterministic outcome counts for both ok and malformed response modes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: fixing a use-after-free bug in proxy-tunnel close dispatch, which directly relates to the changeset's core purpose.
Description check ✅ Passed The description comprehensively addresses both template sections: it explains what the PR does (the UAF bug and fix), verifies the code works (red/green testing with ASan, comprehensive verification table), and includes a detailed review map.
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.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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 Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Thanks for tracking this down, and for the fixture. This use-after-free was fixed on main by #31959 (merged June 17, 1ecbea9d6d), which references this PR: instead of marking the close as notified from ProxyTunnel::shutdown, SSLWrapper::shutdown(true) now fires the close callback itself before the owner detaches, so the deferred callback in handle_reading has nothing left to run against the freed client, and the same guarantee covers the other fast-shutdown callers. That change also added a coalesced response + close_notify regression test to test/js/bun/http/proxy.test.ts.

I ran this PR's fetch-proxy-tunnel-close-uaf.test.ts (both the ok and malformed modes, 30 proxied fetches each) against current main (04148c8) on a debug ASAN build six times: no AddressSanitizer reports, all runs pass. The only hiccup was one run where the ok case took 4.7s and tripped the default 5s per-test timeout on the debug build, which is unrelated to the bug.

Closing since main already has the fix. If you still see this on a current canary, please open an issue and we will take another look.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants