Skip to content

Bun fetch + HTTPS-over-CONNECT-proxy: raw upstream HTTP/1.1 leaks into response.body #30381

Closed

Description

@Romanderenart

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.headers is populated from the proxy's CONNECT 200 envelope (proxy-agent, connection: close) instead of the upstream's actual headers.
  • response.body receives the raw, unparsed upstream bytes — the literal HTTP/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\n is the end-of-body signal; FIN is not required)

What we expected

  • response.headers reflects the upstream server's headers (content-type: text/event-stream, transfer-encoding: chunked, etc.)
  • response.body yields decoded SSE event bytes (3 events of event: 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:

  1. response.headers are the CONNECT envelope, not the upstream's — leaks the proxy product/version (proxy-agent) and a connection: close that the upstream never sent.
  2. response.body contains 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:

  1. handleOnDataHeaders parses the proxy CONNECT 200 envelope.
  2. handleResponseMetadata returns continue_streaming via 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_stage is not modified, cloneMetadata is not called.
  3. startProxyHandshake runs, proxy_tunnel is now non-null.
  4. handleResponseMetadata is 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 are state.response_message_buffer replay, or regular onData re-entering before the tunnel intercepts at http.zig:2020).
  5. On this second call, proxy_tunnel != null → the proxy_tunneling early-return at http.zig:2921 is bypassed. Code falls through to:
    // src/http/http.zig:3186
    this.state.response_stage = if (this.state.transfer_encoding == .chunked)
        .body_chunk else .body;
    The CONNECT envelope has no Transfer-Encoding: chunked, so response_stage = .body.
  6. Back in handleOnDataHeaders, cloneMetadata() at http.zig:1964 commits the CONNECT envelope as the user-facing response.headers.
  7. Subsequent decrypted upstream bytes from ProxyTunnel.onData dispatch on state.response_stage == .body → handleResponseBody → raw passthrough into response.body.

This matches every observed behavior:

  • two printResponse calls 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:

  1. Client = Bun's native fetch() (other runtimes parse upstream HTTP correctly post-CONNECT)
  2. URL scheme = https:// (HTTP-proxy path, line 1396, doesn't tunnel)
  3. proxy: option set (or HTTPS_PROXY env var)
  4. Upstream uses Transfer-Encoding: chunked
  5. Upstream uses Connection: keep-alive and 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

Additional information

client.js

server.py

Activity

  1. robobun commented on May 8, 2026

    @robobun
    Collaborator

    ✅ 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 into response.headers/response.body), passes with it. Awaiting maintainer review/merge.

  2. added 7 commits that reference this issue on May 8, 2026
    48761c6
    8761b26
    63451ff
    c28db00
    04fc472
    a9435ac
    3ee6fa4
  3. added a commit that references this issue on Jun 2, 2026
    a6543d8
  4. added a commit that references this issue on Jun 2, 2026
    8cf3737
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions