Problem Description
When a client connects to an explicit HTTP proxy listener over TLS (a.k.a. "secure web proxy", i.e. the client's proxy URL is https://proxy:port), mitmproxy is supposed to force http/1.1 ALPN on that outer TLS handshake because it does not support CONNECT over HTTP/2. That logic lives in mitmproxy/addons/tlsconfig.py:254-262:
# Force HTTP/1 for secure web proxies, we currently don't support CONNECT over HTTP/2.
# ...
if len(tls_start.context.layers) == 2 and isinstance(
tls_start.context.layers[0], modes.HttpProxy
):
client_alpn: bytes | None = b"http/1.1"
else:
client_alpn = client.alpn
The guard no longer matches. next_layer._setup_explicit_http_proxy eagerly instantiates both ClientTLSLayer and HttpLayer in a single pass, and Layer.__init__ appends each to context.layers, so by the time tls_start_client fires the stack is [HttpProxy, ClientTLSLayer, HttpLayer] — length 3, not 2. The forced-ALPN branch silently never runs, and ALPN selection falls through to the default callback, which happily picks h2 if the client offered it.
Observable effect: any client whose outer-TLS ALPN offer contains h2 (Node.js's TLS default is ["h2", "http/1.1"], so this covers a large class of HTTP clients built on Node's https / https-proxy-agent) negotiates h2 on the handshake and then receives HTTP/2 SETTINGS frames on a socket where it's waiting for HTTP/1.1 200 Connection established, followed by a protocol error and a broken proxy connection. curl --proxy https://... is not affected because curl only offers http/1.1 ALPN on the outer TLS.
Steps to reproduce the behavior:
- Start mitmproxy as a regular HTTP(S) proxy on a port:
- Connect to it with TLS + ALPN
h2,http/1.1 and try to send an HTTP/1.1 CONNECT. For example:
import socket, ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(["h2", "http/1.1"])
s = ctx.wrap_socket(
socket.create_connection(("localhost", 8080)),
server_hostname="localhost",
)
print("outer ALPN:", s.selected_alpn_protocol()) # 'h2' (bug)
s.sendall(b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
print(s.recv(200)) # HTTP/2 SETTINGS frames, not an HTTP/1.1 200
- Observe that the negotiated ALPN is
h2 and mitmproxy logs HTTP/2 protocol error: Invalid HTTP/2 preamble. Expected: ALPN http/1.1 (or no ALPN) and a HTTP/1.1 200 Connection established response.
Suggested fix
I'm not deeply familiar with the internals of the layer system, so please take this as a starting point rather than a prescription — you'll almost certainly know a cleaner shape for it. But something along these lines in mitmproxy/addons/tlsconfig.py would replace the length-based guard with an intent-based one that doesn't break if the layer stack grows:
if (
tls_start.conn is tls_start.context.client
and tls_start.context.layers
and isinstance(
tls_start.context.layers[0],
(modes.HttpProxy, modes.HttpUpstreamProxy),
)
and tls_start.context.server.address is None
and sum(
1
for layer in tls_start.context.layers
if isinstance(layer, proxy_tls.ClientTLSLayer)
) == 1
):
client_alpn: bytes | None = b"http/1.1"
else:
client_alpn = client.alpn
The accompanying test test_no_h2_proxy might also be worth updating to build the layer stack via _setup_explicit_http_proxy (or at least to include a real ClientTLSLayer + HttpLayer) rather than [modes.HttpProxy(ctx), 123], so this class of regression is harder to miss in the future.
Happy to put up a PR if this direction looks right, or to adjust if you'd prefer a different approach - no strong opinions on my end.
System Information
Mitmproxy: 12.2.2 Python: 3.13.3
OpenSSL: OpenSSL 3.5.4 30 Sep 2025
Platform: macOS-15.2-arm64-arm-64bit-Mach-O
Checklist
Problem Description
When a client connects to an explicit HTTP proxy listener over TLS (a.k.a. "secure web proxy", i.e. the client's proxy URL is
https://proxy:port), mitmproxy is supposed to forcehttp/1.1ALPN on that outer TLS handshake because it does not support CONNECT over HTTP/2. That logic lives inmitmproxy/addons/tlsconfig.py:254-262:The guard no longer matches.
next_layer._setup_explicit_http_proxyeagerly instantiates bothClientTLSLayerandHttpLayerin a single pass, andLayer.__init__appends each tocontext.layers, so by the timetls_start_clientfires the stack is[HttpProxy, ClientTLSLayer, HttpLayer]— length 3, not 2. The forced-ALPN branch silently never runs, and ALPN selection falls through to the default callback, which happily picksh2if the client offered it.Observable effect: any client whose outer-TLS ALPN offer contains
h2(Node.js's TLS default is["h2", "http/1.1"], so this covers a large class of HTTP clients built on Node'shttps/https-proxy-agent) negotiatesh2on the handshake and then receives HTTP/2 SETTINGS frames on a socket where it's waiting forHTTP/1.1 200 Connection established, followed by a protocol error and a broken proxy connection.curl --proxy https://...is not affected because curl only offershttp/1.1ALPN on the outer TLS.Steps to reproduce the behavior:
h2,http/1.1and try to send an HTTP/1.1 CONNECT. For example:h2and mitmproxy logsHTTP/2 protocol error: Invalid HTTP/2 preamble. Expected: ALPNhttp/1.1(or no ALPN) and aHTTP/1.1 200 Connection establishedresponse.Suggested fix
I'm not deeply familiar with the internals of the layer system, so please take this as a starting point rather than a prescription — you'll almost certainly know a cleaner shape for it. But something along these lines in
mitmproxy/addons/tlsconfig.pywould replace the length-based guard with an intent-based one that doesn't break if the layer stack grows:The accompanying test
test_no_h2_proxymight also be worth updating to build the layer stack via_setup_explicit_http_proxy(or at least to include a realClientTLSLayer+HttpLayer) rather than[modes.HttpProxy(ctx), 123], so this class of regression is harder to miss in the future.Happy to put up a PR if this direction looks right, or to adjust if you'd prefer a different approach - no strong opinions on my end.
System Information
Checklist