Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2811,6 +2811,64 @@ impl<'a> HTTPClient<'a> {
buffer: &mut bun_io::StreamBuffer,
data: &[u8],
) -> Result<bool, bun_core::Error> {
// When tunneling (CONNECT + inner TLS) the chunked body must go through
// the inner SSL wrapper; `socket` is the outer proxy connection and
// writing plaintext to it would corrupt the encrypted stream. The
// wrapper's `write_encrypted` callback pushes the ciphertext onto the
// outer socket.
if let Some(proxy_ptr) = self.proxy_tunnel.as_ref().map(|p| p.as_ptr()) {
// Detached upgrade so `&mut self` can be reborrowed below; the
// tunnel is a disjoint heap allocation (see
// `proxy_tunnel::raw_as_mut` INVARIANT).
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!(ConnectionClosed) => return Err(e),
Err(_) => {
// WantRead/WantWrite from the inner SSL: treat as
// backpressure — queue any new data and retry on
// the next onWritable.
if !data.is_empty() {
let _ = buffer.write(data);
}
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);
}
return Ok(false);
}
Err(e) if e == err!(ConnectionClosed) => return Err(e),
Comment on lines +2840 to +2862

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.

🔴 Propagating Err(ConnectionClosed) from ProxyTunnel::write here is unsafe: on a fatal inner-TLS write error, SSLWrapper::write_data has already synchronously fired on_close → close_and_fail → fail → dispatch_result_and_reset (which resets state and may free the AsyncHTTP embedding *self) before returning the Err. The caller (write_to_stream, lib.rs:2946-2951) then touches self again — request_stream_detach() + a second close_and_fail — on a potentially-freed client whose stage guard has been reset, double-dispatching the result callback. The pre-existing ProxyTunnel::write call sites (lib.rs:3146-3148, 3215-3217) deliberately do let Ok(...) else { return; } for exactly this reason; this branch should follow the same convention and return Ok(true) on any Err, letting on_close own the failure path.

Extended reasoning...

What the bug is

The new proxy-tunnel branch in write_to_stream_using_buffer (lines 2840 and 2862) propagates Err(ConnectionClosed) from ProxyTunnel::write back up to write_to_stream, intending the caller's existing Err arm to run close_and_fail. But for a fatal inner-SSL write error, ProxyTunnel::write has already synchronously run on_close → close_and_fail → fail() → dispatch_result_and_reset() before it returns. So when control unwinds back to write_to_stream's Err arm at lib.rs:2946-2951, it touches *self (calls stream_buffer.release(), self.request_stream_detach(), self.close_and_fail(...)) on an HTTPClient that has already been failed, reset, and possibly freed.

Step-by-step proof

Setup: fetch() with a ReadableStream body to an https:// origin through any HTTP(S) proxy. Mid-upload, the origin sends a TLS alert / close_notify (e.g. it rejects an oversized body early and tears down), so the next SSL_write on the inner wrapper returns ≤0 with a non-WANT_* error (SSL_ERROR_SSL / SSL_ERROR_ZERO_RETURN / SSL_ERROR_SYSCALL).

  1. write_to_stream (lib.rs:2944) holds &mut self: HTTPClient and stream_buffer, and calls write_to_stream_using_buffer.
  2. The new branch at lib.rs:2819 sees self.proxy_tunnel.is_some() and calls ProxyTunnel::write(proxy, ...) (line 2826 or 2853).
  3. ProxyTunnel::write → SSLWrapper::write_data (uws/lib.rs:761). SSL_write fails with a non-WANT_* error. At uws/lib.rs:780 it calls self.trigger_close_callback() before returning Err(WriteDataError::ConnectionClosed) at line 781.
  4. trigger_close_callback (uws/lib.rs:827-833) synchronously invokes (handlers.on_close)(ctx) = ProxyTunnel's on_close (ProxyTunnel.rs:510).
  5. In on_close, during request-body upload in_progress is true but response_stage is ProxyHeaders (not Body) and the chunked-trailers branch doesn't match, so it falls through to this.close_and_fail(err, socket) at ProxyTunnel.rs:574/577.
  6. close_and_fail → fail() (lib.rs:3722): close_proxy_tunnel(true) (drops self.proxy_tunnel), the stage != Done && stage != Fail guard passes, sets stage = Fail, then calls dispatch_result_and_reset(true).
  7. dispatch_result_and_reset (lib.rs:1391) calls self.state.reset() — which (InternalState.rs:178) does *self = InternalState{...}, resetting stage back to its default (no longer Fail) and calling original_request_body.deinit() — then callback.run(parent_async_http(), result). The codebase explicitly documents at lib.rs:3336-3342 that this callback "can free the AsyncHTTP that embeds *self. Touching self after ... would be a use-after-free."
  8. Control unwinds: write_data returns Err(ConnectionClosed) → ProxyTunnel::write returns Err(err!(ConnectionClosed)) → the new Err(e) if e == err!(ConnectionClosed) => return Err(e) arm at line 2840/2862 propagates it.
  9. write_to_stream's Err arm (lib.rs:2946-2951) now runs:
    • stream_buffer.release() — but state.reset() already deinit'd original_request_body (the Stream variant whose ThreadSafeStreamBuffer this was derived from);
    • self.request_stream_detach() — touches *self after a possible free;
    • self.close_and_fail::<IS_SSL>(err, socket) — re-enters fail(), and since state.reset() reset stage away from Fail, the guard at lib.rs:3727 passes and dispatch_result_and_reset fires the result callback a second time on already-deinit'd state.

Why existing code doesn't prevent it

The stage != Done && stage != Fail guard in fail() would normally make a second close_and_fail a no-op, but dispatch_result_and_reset calls state.reset() which assigns *self = InternalState{...}, resetting stage to its default. So the guard no longer holds on the second entry. And in the case where the result callback frees the AsyncHTTP synchronously, the guard is read from freed memory.

The two pre-existing ProxyTunnel::write call sites — ProxyBody::Bytes at lib.rs:3146-3148 and ProxyHeaders at lib.rs:3215-3217 — deliberately do let Ok(...) = ProxyTunnel::write(...) else { return; } and touch nothing on Err, with the explicit comment "just wait and retry when onWritable! if closed internally will call proxy.onClose". That is the established contract: on_close owns the failure path and the caller must never touch self after an Err from ProxyTunnel::write. The new branch breaks that contract.

Impact

  • Potential use-after-free of the HTTPClient / AsyncHTTP (memory safety).
  • Even when not freed synchronously: double dispatch of the result callback on reset state, and stream_buffer.release() on a deinit'd buffer.

The trigger — origin sending a TLS alert or close_notify while a streamed body chunk is being written through the inner TLS of a CONNECT tunnel — is uncommon but realistic (origin rejects request early with 4xx and tears down, idle timeout mid-upload, corrupted record stream).

Fix

Match the existing call-site convention: on any Err from ProxyTunnel::write, do not propagate. For ConnectionClosed specifically, just return Ok(true) (or buffer pending data and return Ok(true)) and let the already-fired on_close own the failure path, exactly as lib.rs:3146-3148 / 3215-3217 do. This keeps write_to_stream from ever touching self after a possible synchronous free.

Err(_) => {
let _ = buffer.write(data);
return Ok(true);
}
}
}
return Ok(false);
}

let to_send_len = buffer.slice().len();
if to_send_len > 0 {
let amount = write_to_socket::<IS_SSL>(socket, buffer.slice())?;
Expand Down Expand Up @@ -3176,7 +3234,12 @@ impl<'a> HTTPClient<'a> {
);
}

let has_sent_body = self.request_body().is_empty();
let has_sent_body =
if matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)) {
self.request_body().is_empty()
} else {
false
};

if has_sent_headers && has_sent_body {
self.state.request_stage = RequestStage::Done;
Expand All @@ -3190,7 +3253,15 @@ impl<'a> HTTPClient<'a> {
let ctx = self.get_ssl_ctx::<IS_SSL>();
self.progress_update::<IS_SSL>(ctx, socket);
}
debug_assert!(!self.request_body().is_empty());
debug_assert!(
// we should have leftover data OR we use sendfile/stream
(matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_))
&& !self.request_body().is_empty())
|| matches!(
self.state.original_request_body,
HTTPRequestBody::Sendfile(_) | HTTPRequestBody::Stream(_)
)
);

// we sent everything, but there's some body leftover
if amount == to_send.len() {
Expand Down
46 changes: 46 additions & 0 deletions test/js/bun/http/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,52 @@ for (const proxy_tls of [false, true]) {
}
}

// Streamed request bodies through a CONNECT tunnel: after the inner TLS
// handshake completes and the request headers are written, the body must be
// written through the tunnel's inner TLS wrapper (not the outer proxy socket)
// and the ProxyHeaders stage must not short-circuit to Done just because the
// synchronous `request_body` slice is empty for the Stream variant. Before
// the fix both happened, so the origin waited forever for a body that was
// never sent.
describe("streamed request body through CONNECT tunnel", () => {
const streamedBodies = {
ReadableStream: () =>
new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode("hello, "));
c.enqueue(new TextEncoder().encode("tunneled "));
c.enqueue(new TextEncoder().encode("world"));
c.close();
},
}),
"async iterator": () =>
(async function* () {
yield new TextEncoder().encode("hello, ");
yield new TextEncoder().encode("tunneled ");
yield new TextEncoder().encode("world");
})(),
} as const;

for (const proxy_tls of [false, true]) {
for (const [kind, makeBody] of Object.entries(streamedBodies)) {
test(`${kind} body through ${proxy_tls ? "TLS" : "non-TLS"} proxy -> TLS target`, async () => {
const response = await fetch(httpsServer.url, {
method: "POST",
proxy: proxy_tls ? httpsProxyServer.url : httpProxyServer.url,
headers: { "Content-Type": "text/plain" },
keepalive: false,
body: makeBody() as any,
duplex: "half",
tls: { ca: tlsCert.cert, rejectUnauthorized: false },
});
const result = await response.text();
expect(result).toBe("hello, tunneled world");
expect(response.status).toBe(200);
});
}
}
});

for (const server_tls of [false, true]) {
describe.concurrent(`proxy can handle redirects with ${server_tls ? "TLS" : "non-TLS"} server`, () => {
test("with empty body #12007", async () => {
Expand Down
Loading