Post-quantum TLS breaks Chromium behind a terminating proxy: a 60-line MITM fix
I hit a wall that looked like a total network outage and turned out to be a TLS
handshake nobody warns you about. If you run headless Chromium (or Playwright)
inside a sandbox that routes outbound HTTPS through a TLS-terminating egress
proxy, you may find that the browser can't load any page — while curl
through the exact same proxy works fine.
The symptom
Every navigation fails immediately:
net::ERR_CONNECTION_RESET at https://example.com/
Not one host — all of them. Meanwhile, through the same proxy:
curl -x "$HTTPS_PROXY" --cacert "$CA_BUNDLE" https://example.com/ # HTTP 200
So the network is up, the proxy is up, credentials and CA are fine. Only the browser is broken, and it's broken everywhere. That pattern — works in curl, dies in Chromium, on every site — points at the handshake, not the network.
The diagnosis
Chromium's --log-net-log is the fastest way in. Capture it:
chromium.launch({
args: ['--log-net-log=/tmp/netlog.json', '--net-log-capture-mode=Everything']
})
The relevant events show the CONNECT tunnel to the proxy succeeding, then the TLS handshake to the origin getting torn down:
{"headers": ["HTTP/1.1 200 Connection Established"]} ← tunnel is fine
{"net_error": -101, "os_error": 104} ← ECONNRESET…
{"error_lib": 35, "error_reason": 101, "ssl_error": 1, ← …at the TLS layer
"file": "net/socket/socket_bio_adapter.cc"}
net_error -101 is ERR_CONNECTION_RESET; os_error 104 is ECONNRESET. The
key detail: it happens after the proxy CONNECT returns 200, during the TLS
handshake to the destination. Something is resetting the TLS connection itself.
Why curl works and Chromium doesn't
The egress proxy re-terminates TLS (it's a policy/inspection proxy — it decrypts, inspects, re-encrypts). To do that it has to parse the ClientHello.
Recent Chromium (≥131, and I confirmed it on 141, which is what Playwright
bundles) enables a post-quantum key exchange by default: it puts an
X25519MLKEM768 (hybrid ML-KEM / Kyber) key share in the ClientHello. That key
share is large — around a kilobyte — which pushes the ClientHello past the
~1500-byte MTU, so it gets split across multiple TCP segments. A lot of
middleboxes mishandle a hello that doesn't arrive in one piece. This TLS-
terminating proxy's response is a TCP reset.
curl and Node's https don't offer a post-quantum key share, so their
ClientHello is small and fits in a single segment, and the proxy is happy to
terminate it. Same proxy, same destination, different handshake — curl connects,
Chromium gets reset.
This is a known class of breakage (it's why the Chromium team gated PQ behind a
kill switch), but the failure mode is opaque: you get a blanket
ERR_CONNECTION_RESET, not "your post-quantum handshake was rejected."
The Chromium PQ flags (none worked)
The obvious move is to turn PQ off. In my environment, none of these fixed it on Chromium 141:
--disable-features=PostQuantumKyber
--disable-features=EncryptedClientHello
--disable-features=PostQuantumKeyAgreementTLS
--disable-quic
--ssl-version-min=tls1.2
Flag names drift between versions and some are no-ops once the code path is renamed, so I stopped chasing the right incantation and solved it at a different layer.
The fix: a tiny local MITM proxy
If the problem is Chromium's ClientHello reaching the egress proxy, put
something in between that Chromium can talk to happily and that re-originates the
request the way curl/Node do:
Chromium ──TLS (self-signed cert, errors ignored)──▶ local MITM proxy
──plaintext (we can read it)──▶
──CONNECT tunnel via $HTTPS_PROXY, Node TLS──▶ real origin
Chromium now does TLS to our localhost proxy — we accept its post-quantum hello without complaint — and our proxy makes the outbound connection with Node, whose hello the egress proxy accepts. The reset never happens because Chromium's problematic hello never reaches the thing that resets it.
Because our proxy terminates TLS, it also sees every request in plaintext, which makes it a network tracer for free. Here's the whole thing.
const net = require('net'), tls = require('tls'), http = require('http'),
fs = require('fs'), url = require('url');
const CA = fs.readFileSync(process.env.CA_BUNDLE); // to verify real origins
const KEY = fs.readFileSync('mitm.key'), CRT = fs.readFileSync('mitm.crt');
const AP = new url.URL(process.env.HTTPS_PROXY);
const log = [];
// Open a TLS socket to host:port through the egress proxy — the curl/Node path
// the terminator accepts.
function originTLS(host, port, cb) {
const s = net.connect(+AP.port, AP.hostname, () =>
s.write(`CONNECT ${host}:${port} HTTP/1.1\r\nHost: ${host}:${port}\r\n\r\n`));
let hdr = '', done = false;
s.on('data', function onData(d) {
if (done) return;
hdr += d.toString('latin1');
if (hdr.indexOf('\r\n\r\n') < 0) return;
done = true; s.removeListener('data', onData);
if (!/^HTTP\/1\.[01] 200/.test(hdr)) return cb(new Error(hdr.split('\r\n')[0]));
const t = tls.connect({ socket: s, servername: host, ca: CA,
ALPNProtocols: ['http/1.1'] }, () => cb(null, t));
t.on('error', cb);
});
s.on('error', e => { if (!done) cb(e); });
}
// Receives DECRYPTED requests from Chromium and forwards them out.
const server = http.createServer((req, res) => {
const host = req.socket.__host, port = req.socket.__port || 443;
log.push({ host, path: req.url, method: req.method }); // <-- the trace
originTLS(host, port, (err, ot) => {
if (err) { res.writeHead(502); return res.end(err.message); }
const headers = { ...req.headers, host, connection: 'close' };
delete headers['proxy-connection'];
const preq = http.request({ createConnection: () => ot, host, port,
method: req.method, path: req.url, headers },
pres => { res.writeHead(pres.statusCode, pres.headers); pres.pipe(res); });
preq.on('error', e => { if (!res.headersSent) res.writeHead(502); res.end(e.message); });
req.pipe(preq);
});
});
// Accept CONNECT, terminate TLS with our cert, hand the decrypted duplex to
// `server` as if it were a normal connection.
net.createServer(sock => {
sock.once('data', d => {
const m = d.toString('latin1').split('\r\n')[0].match(/^CONNECT ([^:]+):(\d+)/);
if (!m) { sock.write('HTTP/1.1 405\r\n\r\n'); return sock.end(); }
sock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
const tsock = new tls.TLSSocket(sock, { isServer: true, key: KEY, cert: CRT,
ALPNProtocols: ['http/1.1'] });
tsock.__host = m[1]; tsock.__port = +m[2];
tsock.on('error', () => {});
server.emit('connection', tsock); // feed decrypted stream to the http server
});
sock.on('error', () => {});
}).listen(0, '127.0.0.1', function () {
console.log('MITM_PORT=' + this.address().port);
});
Two tricks make this small:
server.emit('connection', tlsSocket)— Node'shttp.Serverwill parse HTTP/1.1 off any duplex stream you hand it, including aTLSSocketyou've already completed the handshake on. No need to bind a real port for it.- ALPN
http/1.1only — advertising just HTTP/1.1 on the terminating side forces Chromium to speak 1.1, so parsing and forwarding stay trivial. (Don't ship this as a general h2 proxy; for tracing, 1.1 is plenty.)
One-time cert (SAN barely matters — the browser runs with
--ignore-certificate-errors):
openssl req -x509 -newkey rsa:2048 -keyout mitm.key -out mitm.crt -days 2 -nodes \
-subj "/CN=trace-proxy" -addext "subjectAltName=DNS:localhost,DNS:*.com"
Point the browser at it — not at $HTTPS_PROXY:
chromium.launch({
proxy: { server: 'http://127.0.0.1:' + MITM_PORT },
args: ['--ignore-certificate-errors', '--no-sandbox', '--disable-quic',
'--disable-background-networking', '--disable-component-update'],
});
--disable-quic matters: it forces TCP, so nothing sneaks out over UDP/HTTP-3
and bypasses your proxy.
Request-level tracing
Once the proxy is running and pages load, the log array fills up on its own.
Because every decrypted request lands in it, you get a ground-truth record of
what a page actually did — not what its CSP permitted, which is a different
and usually much longer list. A few things this is good for:
- Which analytics/trackers really fire on load, vs. sit dormant in the CSP.
- Upload pipelines: where files go, and whether the stored object is public.
Capture the request body, or just re-fetch the stored URL with
curland inspect it — e.g., to check whether an image upload preserved its EXIF/GPS. - Form behavior: whether an autocomplete field calls a third party directly or gets proxied server-side; where payment tokens are minted; etc.
Caveats
- HTTP/1.1 only, by choice. Fine for tracing; not a general-purpose proxy.
- One self-signed cert for every host works only because the browser ignores cert errors. Never reuse this where certificate identity matters.
- Verify the premise first. If
curl -x "$HTTPS_PROXY" https://example.comalso fails, your problem is egress policy (403/407), not the PQ handshake — don't reach for this. - CONNECT parsing assumes one chunk. The
sock.once('data', ...)handler reads the CONNECT line from the first TCP segment. Safe for localhost, where the request arrives whole; not something to rely on over a real network. - If a future Chromium/proxy combination stops resetting the PQ hello, you won't need the interposer to reach the network — but it still works as a pure trace capture.
The whole thing is ~60 lines and has saved me from re-diagnosing a blanket
ERR_CONNECTION_RESET more than once.