Repository navigation
Description
What version of Bun is running?
1.3.14
What platform is your computer?
Linux 5.15.0-106-generic x86_64 x86_64
What steps can reproduce the bug?
What version of Bun is running?
1.3.14
What platform is your computer?
Linux 5.15.0-106-generic x86_64 x86_64
What steps can reproduce the bug?
Background
I hit this trying to run Claude Code (Anthropic's CLI) through an HTTP CONNECT proxy. Every request would hang for ~10s and then time out. Claude Code uses Bun's native fetch(), and the Anthropic API endpoint serves chunked SSE over keep-alive HTTPS — exactly the combination this bug requires. Direct connections (no proxy) work fine; the same proxy works fine with curl, Node, and browsers, which is what pointed at Bun's CONNECT-tunnel code path.
Investigation and minimization to the repro below were done with Claude's help.
Summary
When Bun's fetch() is configured with an HTTP proxy: (or HTTPS_PROXY env var) and the target is https://, Bun establishes the CONNECT tunnel correctly but then fails to parse the upstream's HTTP/1.1 response inside the tunnel:
response.headersis populated from the proxy's CONNECT 200 envelope (proxy-agent,connection: close) instead of the upstream's actual headers.response.bodyreceives the raw, unparsed upstream bytes — the literalHTTP/1.1 200 OK\r\n…status line, all upstream headers, every chunked-encoding size prefix (1b\r\n), and the chunked terminator (0\r\n\r\n) — all delivered as body data.- Because the chunked terminator is never recognized, the stream only ends on TCP FIN. A spec-compliant keep-alive upstream (RFC 7230 §4.1 — terminator is the end-of-body signal, FIN not required) hangs the request indefinitely.
Direct mode (no proxy) works correctly. curl, Node/undici, Go, Python requests, and browsers all handle this case correctly. Source-code analysis below points at src/http/http.zig handleResponseMetadata being invoked twice with the CONNECT envelope and committing it as the user-facing response on the second call.
Repro
# 1. self-signed cert
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 1 -nodes \
-subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1'
# 2. start the upstream server (chunked SSE, keep-alive, no FIN) in one terminal
python3 server.py
# 3. start any HTTP CONNECT forward proxy on 127.0.0.1:8888 in another terminal
# (e.g. tinyproxy, gost, mitmproxy --mode regular, or write 30 lines yourself). When finding the bug, gost is used as the proxy.
# 4. baseline: direct mode works fine
bun client.js direct
# → exits 0 in ~150ms with 3 decoded SSE events
# 5. bug: proxy mode hangs and dumps raw upstream HTTP/1.1 bytes into response.body
HTTPS_PROXY=http://127.0.0.1:8888 bun client.js proxy
# → response.headers contains the CONNECT envelope, NOT the upstream's headers
# → response.body chunks contain raw "HTTP/1.1 200 OK\r\n...", chunk size prefixes, terminator
# → hangs 10s waiting for FIN that never comes (server is keep-alive, RFC 7230 4.1 compliant)Environment
- Bun 1.3.14 (verified on Linux 5.15.0-106-generic x86_64, Ubuntu 22.04.4 LTS, glibc 2.35)
- Any HTTP CONNECT forward proxy (the bug is independent of the specific proxy implementation)
- Any HTTPS upstream that uses chunked transfer-encoding + keep-alive without prompt FIN (this is spec-compliant per RFC 7230 §4.1 — chunked terminator
0\r\n\r\nis the end-of-body signal; FIN is not required)
What we expected
response.headersreflects the upstream server's headers (content-type: text/event-stream,transfer-encoding: chunked, etc.)response.bodyyields decoded SSE event bytes (3 events ofevent: test\ndata: chunk N\n\n)- Stream ends when chunked terminator is parsed (~150ms), no hang
This is exactly what bun client.js direct does.
What actually happens with proxy: set
< 200 Connection established ← printResponse #1 (proxy CONNECT envelope)
< Connection: close
< Proxy-Agent: <proxy>
< 200 Connection established ← printResponse #2 (same envelope, again)
< Connection: close
< Proxy-Agent: <proxy>
[t=0.105s] <-- status=200
[t=0.105s] full response.headers:
[t=0.105s] connection: close
[t=0.105s] proxy-agent: <proxy> ← user-facing headers are the CONNECT envelope!
[t=0.107s] chunk #1 162B
[t=0.107s] hex: 485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a...
[t=0.107s] asc: HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-E...
↑ raw upstream HTTP/1.1 envelope appearing as body chunk
[t=0.112s] chunk #2 4B
[t=0.112s] hex: 31620d0a
[t=0.112s] asc: 1b\r\n ← raw chunk size prefix (0x1b = 27)
[t=0.118s] chunk #3 29B
[t=0.118s] asc: event: test\ndata: chunk 1\n\n\r\n
...
[t=0.212s] chunk #6 5B
[t=0.212s] asc: 0\r\n\r\n ← raw chunked terminator, treated as body data
[t=10.001s] !!! HANG (10s wd)
Two distinct symptoms:
response.headersare the CONNECT envelope, not the upstream's — leaks the proxy product/version (proxy-agent) and aconnection: closethat the upstream never sent.response.bodycontains raw upstream HTTP/1.1 bytes — full envelope, chunk size prefixes (1b\r\n), and the chunked terminator (0\r\n\r\n) all delivered as body data without parsing.
Because no HTTP parser ever recognizes the chunked terminator on this code path, the stream only ends when the underlying socket FINs. A spec-compliant keep-alive server holds the socket open after the terminator → indefinite hang.
Verbose-fetch evidence
Run with BUN_CONFIG_VERBOSE_FETCH=true bun client.js proxy. The CONNECT envelope is printed twice by printResponse. Bun has exactly one call site for printResponse (src/http/http.zig:2886, inside handleResponseMetadata). Two prints means handleResponseMetadata was invoked twice with the CONNECT envelope's bytes. The upstream's actual HTTP/1.1 200 OK response line is never printed — even though its raw bytes appear in the body chunks above.
Source-code analysis (Bun master @ 9ed6e89)
src/http/ProxyTunnel.zig correctly handles upstream chunked encoding when state.response_stage == .body_chunk. The bug is that for this scenario state.response_stage ends up as .body, not .body_chunk, so the data is dispatched to handleResponseBody (raw passthrough) instead of handleResponseBodyChunkedEncoding (the chunked parser).
The likely path:
handleOnDataHeadersparses the proxy CONNECT 200 envelope.handleResponseMetadatareturnscontinue_streamingvia the early-return at:// src/http/http.zig:2921 if (this.flags.proxy_tunneling and this.proxy_tunnel == null) { if (response.status_code == 200) { return ShouldContinue.continue_streaming; } ... }
state.response_stageis not modified,cloneMetadatais not called.startProxyHandshakeruns,proxy_tunnelis now non-null.handleResponseMetadatais invoked again with the same CONNECT envelope bytes (this is the bit I haven't fully nailed down — happy to dig in if you can point me at the right place; suspects arestate.response_message_bufferreplay, orregular onDatare-entering before the tunnel intercepts athttp.zig:2020).- On this second call,
proxy_tunnel != null→ theproxy_tunnelingearly-return athttp.zig:2921is bypassed. Code falls through to:The CONNECT envelope has no// src/http/http.zig:3186 this.state.response_stage = if (this.state.transfer_encoding == .chunked) .body_chunk else .body;
Transfer-Encoding: chunked, soresponse_stage = .body. - Back in
handleOnDataHeaders,cloneMetadata()athttp.zig:1964commits the CONNECT envelope as the user-facingresponse.headers. - Subsequent decrypted upstream bytes from
ProxyTunnel.onDatadispatch onstate.response_stage == .body→handleResponseBody→ raw passthrough intoresponse.body.
This matches every observed behavior:
- two
printResponsecalls of the CONNECT envelope - user-facing headers are the CONNECT envelope
- response.body contains raw
HTTP/1.1 200 OK\r\n...envelope, chunk size prefixes, and the chunked terminator - no chunked-end detection → hang waiting for FIN
Why nobody else notices
The combination required to expose this is narrow:
- Client = Bun's native
fetch()(other runtimes parse upstream HTTP correctly post-CONNECT) - URL scheme =
https://(HTTP-proxy path, line 1396, doesn't tunnel) proxy:option set (orHTTPS_PROXYenv var)- Upstream uses
Transfer-Encoding: chunked - Upstream uses
Connection: keep-aliveand does not promptly close the socket after the chunked terminator (this is spec-compliant HTTP/1.1)
Most upstreams (Cloudflare-fronted endpoints in particular) FIN almost immediately after the response, which masks the hang because the raw-bytes stream ends on TCP FIN. Strict spec-compliant gateways that keep the connection open hang indefinitely.
Other clients are unaffected
curl, Node.js fetch/undici, Go net/http, Python requests, Rust hyper/reqwest, and browsers all handle this correctly. Bun's own fetch() in direct mode (no proxy: option) also handles it correctly — its HTTP/1.1 parser is fine; the CONNECT-proxy path is the only one affected.
Repro files
Attaching server.py and client.js (each ~50 lines, no external deps beyond Python's stdlib + Bun + a CONNECT proxy of the reader's choice).
I am very sorry about the previous issue with no attachment.
What is the expected behavior?
No response
What do you see instead?
No response
Activity
robobun commentedon May 8, 2026
✅ Reproduced with a CONNECT proxy that splits the 200 Established envelope across two TCP reads. Root cause: the response accumulation buffer was not cleared after the envelope was consumed, so decrypted upstream bytes arriving inside the tunnel were appended onto the stale envelope and re-parsed as the user-facing response.
Fix in #30385 — clears the buffer (in the Rust HTTP client,
src/http/lib.rs) before the tunnel starts, via a UAF-safe ownership transfer. Regression test verified: fails without the fix (envelope leaks intoresponse.headers/response.body), passes with it. Awaiting maintainer review/merge.fetch(proxy): clear response buffer after CONNECT envelope handoff
fetch(proxy): clear response buffer after CONNECT envelope handoff
fetch(proxy): clear response buffer after CONNECT envelope handoff
fetch(proxy): clear response buffer after CONNECT envelope handoff
fetch(proxy): clear response buffer after CONNECT envelope handoff
fetch(proxy): clear response buffer after CONNECT envelope handoff
fetch(proxy): clear response buffer after CONNECT envelope handoff
fetch(proxy): clear response buffer after CONNECT envelope handoff
fetch(proxy): clear response buffer after CONNECT envelope handoff (#…
http: remove lifetime-launder workarounds in client body/buffer and R…
http: remove lifetime-launder workarounds in client body/buffer and R…