[BUG] Cloud egress gateway resets any TLS ClientHello carrying the ECH extension, so Chromium/Playwright can never connect while curl can

Status Open
Maintainer reply None cached
Activity 0 comments · opened Sep 9, 2026

What's wrong?

In Claude Code on the web (cloud VM sessions), curl reaches every allowlisted host through the agent proxy in under a second. A headless Chromium on the same VM, with the same HTTPS_PROXY, gets net::ERR_CONNECTION_RESET from every allowlisted host. The CONNECT is accepted, Chromium sends its ClientHello, and nothing comes back until the tunnel is torn down about six seconds later.

We bisected it at the byte level. The upstream egress gateway drops any ClientHello that carries the encrypted_client_hello extension (type 0xfe0d). Chromium sends a GREASE ECH extension on every connection and current builds have no switch to turn it off, so the browser can never get through. curl and Node's OpenSSL do not send ECH, which is why they work.

This makes Playwright, Puppeteer and headless Chrome unusable against any external host in cloud sessions. It is the same symptom family as #11791; the CONNECT is now accepted and the tunnel dies on the ClientHello instead.

Environment

  • Claude Code on the web, cloud VM session: CLAUDE_CODE_REMOTE=true, CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE=cloud_default
  • Environment id env_013uiZHqZZLr3e9d8BeWvpms, container container_015QtS1XPNkSPqZpamsC5PZu--claude_code_remote--f2ce73, measured 2026-09-09 09:06 to 09:26 UTC
  • Ubuntu 24.04.4 x86_64, Node 24.11.1 (OpenSSL 3.5.4), curl 8.5.0 (OpenSSL 3.0.13), playwright-core 1.61.1, Chromium 149.0.7827.55 (Playwright build 1228)
  • Proxy: HTTPS_PROXY=http://127.0.0.1:45691, CCR_AGENT_PROXY_ENABLED=1, CCR_UPSTREAM_PROXY_ENABLED=1, CCR_EGRESS_GATEWAY_ENABLED=1, CLAUDE_CODE_PROXY_RESOLVES_HOSTS=true
  • CA in /root/.ccr/ca-bundle.crt: CN = CCR Upstream Proxy CA (staging), O = Anthropic

Exact signature

Measured behind a byte-counting CONNECT relay placed between Chromium and the agent proxy, so the counts are the bytes the agent proxy saw:

Chromium -> https://mcr.microsoft.com/   (allowlisted; curl gets HTTP 200)
CONNECT mcr.microsoft.com:443            reply "HTTP/1.1 200 Connection Established" (39 bytes)
client -> upstream  1791 bytes           (one TLS record: the ClientHello)
upstream -> client  0 bytes
6374 ms later       upstream ECONNRESET
Chromium reports    net::ERR_CONNECTION_RESET (or ERR_CONNECTION_CLOSED)

curl -> https://mcr.microsoft.com/       (control, same relay)
CONNECT                                  same 39-byte reply
client -> upstream  1318 bytes           (ClientHello 517 bytes, then the request)
upstream -> client  18106 bytes          HTTP 200

Same result for archive.ubuntu.com and raw.githubusercontent.com. api.github.com is different, see below.

The bisection

  1. Replay. Chromium's captured ClientHello (1831 bytes), written byte-for-byte from a plain Node socket after a hand-made CONNECT, gets the same result: 0 bytes back, ECONNRESET after 6035 ms. The client does not matter, the bytes do.
  2. Not size, not post-quantum. A Node-generated ClientHello with the X25519MLKEM768 key share (1544 bytes) handshakes fine through the same path, and replayed from the same plain socket gets 5970 bytes back in 58 ms.
  3. Drop one extension at a time from Chromium's hello and replay. Dropping the empty GREASE extensions changes nothing. Dropping only encrypted_client_hello (0xfe0d, 190 to 282 bytes) makes the gateway answer: 1735 bytes in, 5995 bytes back.
  4. Graft it the other way. Copying Chromium's ECH extension into the working Node hello turns it into the failing case: 0 bytes back, ECONNRESET after 6033 ms.

Necessary and sufficient: the ECH extension is what the gateway rejects.

Why no client-side knob helps

Before finding the cause we had tried pinning the CA's SPKI, --disable-features=EncryptedClientHello, --no-quic with --ssl-version-max=tls1.2, and an explicit --proxy-server. None moved the signature, and the captures show why: on Chromium 149 the hello still carries the ECH extension under --disable-features=EncryptedClientHello, --disable-features=EncryptedClientHello,EncryptedClientHelloGrease, --disable-features=EncryptedClientHelloGrease and --ech-server-configs= (launch args were verified to reach the browser with a --user-agent probe). The TLS 1.2 cap is ignored too. GREASE ECH is not configurable in current Chromium, and Firefox and WebKit send ECH as well.

Two smaller findings from the same session

  • api.github.com fails differently. Chromium reaches it and gets net::ERR_CERT_AUTHORITY_INVALID. That host is terminated by the proxy itself (credential injection), which evidently tolerates ECH, and the browser then rejects the CCR CA because the NSS store that /root/.ccr/README.md says is set up does not exist (~/.pki/nssdb is absent).
  • The environment's network policy is set to "full", yet several hosts are refused at CONNECT. In this session example.com, mdls.dynamsoftonline.com, cdn.playwright.dev and deb.nodesource.com answered HTTP/1.1 403 Forbidden to CONNECT even for curl, while mcr.microsoft.com, api.github.com, raw.githubusercontent.com and archive.ubuntu.com were allowed. We saw the same on 2026-09-06 on a session started after the policy was widened. Either the full policy is not what the gateway applies, or it does not reach new sessions.

Steps to reproduce (under five minutes)

In any Claude Code on the web session:

# 1. curl works on an allowlisted host
curl -sS -o /dev/null -w 'http=%{http_code}\n' https://mcr.microsoft.com/

# 2. Chromium does not (the image's bundled Chromium under $PLAYWRIGHT_BROWSERS_PATH
#    is enough; playwright-core resolves from any project that has it)
cat > /tmp/probe.mjs <<'JS'
import { chromium } from "playwright-core";
const b = await chromium.launch(); const p = await b.newPage();
try { await p.goto("https://mcr.microsoft.com/", { timeout: 15000 }); console.log("reachable"); }
catch (e) { console.log("unreachable", String(e.message).split("\n")[0]); }
finally { await b.close(); }
JS
node /tmp/probe.mjs
# -> unreachable page.goto: net::ERR_CONNECTION_RESET at https://mcr.microsoft.com/

# 3. Any ClientHello with an ECH extension does the same from a plain socket;
#    the same hello with the 0xfe0d extension removed is answered.

The relay, capture, mutate and graft scripts from step 3 are available on request.

Related

  • #11791 (open): same symptom family, browser automation through the sandbox proxy
  • #15583 (closed): the allowlist side; allowlisting a host fixes curl but not the browser
  • #36363 (closed): the MITM CA side; related to the api.github.com finding, not to the reset

What should happen?

  1. Make the egress gateway tolerate the encrypted_client_hello extension (pass it through, or strip it before forwarding). Every modern browser sends it and none can be told not to.
  2. Confirm whether CCR Upstream Proxy CA (staging) is expected on a production environment, and whether a "full" network policy is meant to allow the hosts above.
  3. Until fixed, a line in the Claude Code on the web docs saying browser egress through the agent proxy does not work would save others the time it cost us.

Happy to run any further probe in our environment and post the output.

View original on GitHub ↗