Skip to content

fetch: send streamed request body through CONNECT proxy tunnel - #32636

Closed
robobun wants to merge 2 commits into
mainfrom
farm/b7397eca/fix-proxy-tunnel-stream-body
Closed

robobun wants to merge 2 commits into
mainfrom
farm/b7397eca/fix-proxy-tunnel-stream-body

Conversation

@robobun

@robobun robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Repro

// run with: NO_PROXY= HTTP_PROXY= HTTPS_PROXY= bun repro.ts
import net from "node:net";
import { once } from "node:events";
import { tls as tlsCert } from "harness";

const origin = Bun.serve({
  port: 0, tls: tlsCert,
  fetch: async req => new Response(await req.text()),
});
const proxy = net.createServer(client => {
  client.on("error", () => {});
  let head = Buffer.alloc(0), upstream: net.Socket | undefined;
  client.on("close", () => upstream?.destroy());
  client.on("data", chunk => {
    if (upstream) { upstream.write(chunk); return; }
    head = Buffer.concat([head, chunk]);
    const end = head.indexOf("\r\n\r\n");
    if (end === -1) return;
    const leftover = head.subarray(end + 4);
    upstream = net.connect(origin.port, "127.0.0.1", () => {
      client.write("HTTP/1.1 200 OK\r\n\r\n");
      if (leftover.length) upstream!.write(leftover);
      upstream!.pipe(client);
    });
    upstream.on("error", () => client.destroy());
    upstream.on("close", () => client.destroy());
  });
});
proxy.listen(0, "127.0.0.1");
await once(proxy, "listening");

const body = new ReadableStream({
  start(c) { c.enqueue(new TextEncoder().encode("hello")); c.close(); },
});
const res = await fetch(`https://localhost:${origin.port}/`, {
  method: "POST", body, duplex: "half",
  proxy: `http://127.0.0.1:${(proxy.address() as net.AddressInfo).port}`,
  keepalive: false, tls: { rejectUnauthorized: false },
  signal: AbortSignal.timeout(3000),
});
console.log(res.status, await res.text()); // expected 200 hello; actual: TimeoutError

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

Cause

Two bugs stacked:

  1. In on_writable's ProxyHeaders arm, has_sent_body = self.request_body().is_empty(). For HTTPRequestBody::Stream the synchronous bytes cursor is always empty, so has_sent_headers && has_sent_body is true and the stage jumps straight to Done; the is_streaming_request_body signal to start pulling chunks from JS never fires. The non-proxy send_initial_request_payload path already computes has_sent_body only for HTTPRequestBody::Bytes.

  2. Even once the stage reaches ProxyBody, that arm's Stream case calls flush_stream → write_to_stream_using_buffer → write_to_socket(socket, …), i.e. the outer proxy socket, writing plaintext chunked bytes into the encrypted tunnel. The Bytes case right above it correctly goes through ProxyTunnel::write.

Fix

  1. In ProxyHeaders, compute has_sent_body the same way as send_initial_request_payload (only true for HTTPRequestBody::Bytes with an empty buffer). Relax the debug_assert!(!self.request_body().is_empty()) below it to also allow Stream/Sendfile, matching the non-proxy Body arm.

  2. In write_to_stream_using_buffer, when self.proxy_tunnel.is_some(), route both the buffered flush and new data through ProxyTunnel::write instead of write_to_socket. WantRead/WantWrite from the inner SSL are treated as backpressure (queue into buffer, return Ok(true)); ConnectionClosed is propagated so the caller's close_and_fail runs. The encrypted output reaches the outer socket via the existing write_encrypted callback, same as the Bytes path. This also covers HTTPThread::drain_queued_writes, which reaches the same helper.

Verification

Before:

$ USE_SYSTEM_BUN=1 bun test test/js/bun/http/proxy.test.ts -t "streamed request body"
(fail) streamed request body through CONNECT tunnel > ReadableStream body through non-TLS proxy -> TLS target [5001ms]  (timed out)
(fail) streamed request body through CONNECT tunnel > async iterator body through non-TLS proxy -> TLS target [5001ms]  (timed out)
(fail) streamed request body through CONNECT tunnel > ReadableStream body through TLS proxy -> TLS target [5001ms]  (timed out)
(fail) streamed request body through CONNECT tunnel > async iterator body through TLS proxy -> TLS target [5001ms]  (timed out)

After:

$ bun bd test test/js/bun/http/proxy.test.ts -t "streamed request body"
(pass) streamed request body through CONNECT tunnel > ReadableStream body through non-TLS proxy -> TLS target
(pass) streamed request body through CONNECT tunnel > async iterator body through non-TLS proxy -> TLS target
(pass) streamed request body through CONNECT tunnel > ReadableStream body through TLS proxy -> TLS target
(pass) streamed request body through CONNECT tunnel > async iterator body through TLS proxy -> TLS target

The full proxy.test.ts (52 tests) and the non-proxy streamed-body suites (async-iterator-stream.test.ts, body-stream.test.ts) still pass.

A fetch() to an https:// origin through an HTTP(S) proxy with a
ReadableStream or async-iterator request body hung forever: the
CONNECT succeeded, the inner TLS handshake completed, the request
headers (Transfer-Encoding: chunked) were written, and then nothing.

Two bugs stacked here:

  - In on_writable's ProxyHeaders arm, has_sent_body was computed as
    request_body().is_empty(), which is always true for the Stream
    variant, so the stage jumped straight to Done and the
    is_streaming_request_body signal to start pulling chunks never
    fired. The non-proxy path (send_initial_request_payload) already
    gated this on HTTPRequestBody::Bytes; do the same here, and relax
    the debug_assert below it to allow Stream/Sendfile.

  - write_to_stream_using_buffer (reached from the ProxyBody arm and
    from HTTPThread's queued-write drain) wrote the chunked body to
    the outer proxy socket directly, putting plaintext into the
    encrypted tunnel. Route it through ProxyTunnel::write when a
    tunnel is attached, same as the Bytes arm already does; treat
    WantRead/WantWrite from the inner SSL as backpressure and
    propagate ConnectionClosed.

Tests cover ReadableStream and async-iterator bodies over both
HTTP-proxy and HTTPS-proxy CONNECT tunnels.
@robobun

robobun commented Jun 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:33 AM PT - Jun 23rd, 2026

❌ @autofix-ci[bot], your commit a10a6cf has 1 failures in Build #64158 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32636

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

bun-32636 --bun

@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e4ed47ef-9bc9-4744-be09-72bdcad117ed

📥 Commits

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

📒 Files selected for processing (2)
  • src/http/lib.rs
  • test/js/bun/http/proxy.test.ts

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

In src/http/lib.rs, write_to_stream_using_buffer is updated to route all writes through ProxyTunnel::write when a tunnel is active, with backpressure buffering and ConnectionClosed propagation. The has_sent_body flag in the ProxyHeaders stage is narrowed to the Bytes body variant, and a debug_assert! is expanded to permit Sendfile/Stream body variants. A new test suite is added for streamed bodies through CONNECT proxies.

Changes

Streamed request body through CONNECT tunnel

Layer / File(s) Summary
Proxy tunnel write path in write_to_stream_using_buffer
src/http/lib.rs
When proxy_tunnel is present, all socket writes are redirected through ProxyTunnel::write. Partial sends advance buffer.cursor and increment request_sent_len; remaining plaintext is requeued into StreamBuffer on backpressure; ConnectionClosed is surfaced directly; buffer is reset when fully drained.
ProxyHeaders stage transition, assert expansion, and tests
src/http/lib.rs, test/js/bun/http/proxy.test.ts
has_sent_body is now true only for HTTPRequestBody::Bytes once the buffer is empty; other body variants (e.g., Stream) are forced to false so the stage advances to ProxyBody instead of Done. The debug_assert! is broadened to allow leftover state for Sendfile and Stream variants. A new describe block tests ReadableStream and async iterator POST bodies tunneled through CONNECT proxies under both TLS and non-TLS proxy modes, asserting HTTP 200 and the exact concatenated response body.

Possibly related PRs

  • oven-sh/bun#31325: Both PRs modify the CONNECT+inner-TLS data path in HTTPClient; the referenced PR gates application writes around checkServerIdentity via is_waiting_for_cert_check and ProxyTunnel::on_data, which directly touches the same proxy tunnel infrastructure.

Suggested reviewers

  • cirospaciari

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. fetch() lasts indefinitely with proxychains #19691 - fetch() hangs indefinitely when routed through a CONNECT proxy (proxychains), matching the ProxyHeaders short-circuit and tunnel write bugs fixed here

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

Fixes #19691

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. http: proxy stress test suite + fix streamed upload and on_writable UAF through tunnel #32635 - Fixes the same two bugs (ProxyHeaders stage short-circuiting to Done for stream bodies, and write_to_stream_using_buffer writing plaintext to outer proxy socket instead of through ProxyTunnel::write), plus an additional UAF fix and larger stress test suite

🤖 Generated with Claude Code

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #32635, which landed a minute earlier and carries the identical two-part fix (the ProxyHeaders has_sent_body gate and the write_to_stream_using_buffer tunnel routing) plus an additional on_writable UAF fix and a much larger proxy stress test suite.

On the suggested link to #19691: that issue is about proxychains, which transparently intercepts connect() via LD_PRELOAD and does its own tunneling at the TCP layer. Bun's HTTP client never sees a proxy in that scenario, so the code paths touched here (ProxyTunnel, RequestStage::ProxyHeaders/ProxyBody) aren't reached. Not linking it.

@robobun robobun closed this Jun 23, 2026
Comment thread src/http/lib.rs
Comment on lines +2840 to +2862
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),

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.

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