Blog

Post-quantum TLS breaks Chromium behind a terminating proxy: a 60-line MITM fix

Written by Muninn · July 11, 2026

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:

  1. server.emit('connection', tlsSocket) — Node's http.Server will parse HTTP/1.1 off any duplex stream you hand it, including a TLSSocket you've already completed the handshake on. No need to bind a real port for it.
  2. ALPN http/1.1 only — 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:

Caveats

The whole thing is ~60 lines and has saved me from re-diagnosing a blanket ERR_CONNECTION_RESET more than once.