Repository navigation
Request hangs when a TLS 1.3 server rejects the client certificate after the handshake #8486
Description
Problem Description
When an upstream server requires a client certificate and rejects mitmproxy's,
either because none was configured or because it isn't trusted, the request
through mitmproxy never gets a response on TLS 1.3. It waits until the client
gives up. On TLS 1.2 the same rejection is a prompt 502 Bad Gateway that names
the OpenSSL error.
In TLS 1.3 the client completes its side of the handshake before the server
checks the client certificate. So the rejection arrives as a fatal alert
(certificate_required, bad_certificate, unknown_ca, ...) after
tls_established_server has fired. mitmproxy logs it:
TLS Error: [('SSL routines', '', 'tlsv13 alert certificate required')]
but the connection is never closed. The HTTP layer keeps waiting for a response
that will never come, and the server's subsequent TCP close is ignored as well.
Steps to reproduce the behavior:
- Save the script below as
tls13_alert_repro.py. It needscryptography, and
mitmdumponPATH. It starts a local HTTPS server that requires a client
certificate, then stockmitmdumpwith no addons and noclient_certs, which
trusts the server's CA throughssl_verify_upstream_trusted_ca. It sends one
request through it. - Run
python tls13_alert_repro.py 1.3. - Run
python tls13_alert_repro.py 1.2for comparison.
"""mitmdump hangs when a TLS 1.3 server rejects the client after the handshake.
Starts a local HTTPS server that requires a client certificate, starts stock
mitmdump (no addons, no client certificate configured), and sends one request
through it. Run with `python tls13_alert_repro.py [1.2|1.3]`; needs `cryptography`
and `mitmdump` on PATH.
"""
import datetime
import os
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
TLS = {"1.2": ssl.TLSVersion.TLSv1_2, "1.3": ssl.TLSVersion.TLSv1_3}[
sys.argv[1] if len(sys.argv) > 1 else "1.3"
]
WORK = tempfile.mkdtemp()
NOW = datetime.datetime.now(datetime.UTC)
def issue(name, issuer=None, *, ca=False, san=None):
key = ec.generate_private_key(ec.SECP256R1())
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer[0].subject if issuer else subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(NOW - datetime.timedelta(days=1))
.not_valid_after(NOW + datetime.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True)
)
if san:
builder = builder.add_extension(
x509.SubjectAlternativeName([x509.DNSName(san)]), critical=False
)
return builder.sign(issuer[1] if issuer else key, hashes.SHA256()), key
def write(name, *parts):
path = os.path.join(WORK, name)
with open(path, "wb") as f:
for part in parts:
if isinstance(part, x509.Certificate):
f.write(part.public_bytes(serialization.Encoding.PEM))
else:
f.write(
part.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
)
return path
server_ca = issue("server CA", ca=True)
client_ca = issue("client CA", ca=True)
leaf = issue("localhost", server_ca, san="localhost")
SERVER_CA = write("server-ca.pem", server_ca[0])
CLIENT_CA = write("client-ca.pem", client_ca[0])
SERVER = write("server.pem", leaf[0], leaf[1])
def serve():
"""An HTTPS server that requires a client certificate signed by CLIENT_CA."""
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(SERVER)
context.load_verify_locations(CLIENT_CA)
context.verify_mode = ssl.CERT_REQUIRED
context.minimum_version = context.maximum_version = TLS
listener = socket.create_server(("127.0.0.1", 0))
def accept():
while True:
conn, _ = listener.accept()
tls = context.wrap_socket(conn, server_side=True, do_handshake_on_connect=False)
try:
tls.do_handshake()
except ssl.SSLError as exc:
print(f"server: rejected client: {exc.reason}")
tls.close()
threading.Thread(target=accept, daemon=True).start()
return listener.getsockname()[1]
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
server_port = serve()
proxy_port = free_port()
mitmdump = subprocess.Popen(
[
"mitmdump",
"--listen-port", str(proxy_port),
"--set", f"confdir={WORK}",
"--set", f"ssl_verify_upstream_trusted_ca={SERVER_CA}",
]
)
for _ in range(100):
try:
socket.create_connection(("127.0.0.1", proxy_port), timeout=0.1).close()
break
except OSError:
time.sleep(0.1)
start = time.monotonic()
try:
raw = socket.create_connection(("127.0.0.1", proxy_port), timeout=15)
authority = f"localhost:{server_port}"
raw.sendall(f"CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n\r\n".encode())
while b"\r\n\r\n" not in raw.recv(4096):
pass
client = ssl.create_default_context(cafile=os.path.join(WORK, "mitmproxy-ca-cert.pem"))
tunnel = client.wrap_socket(raw, server_hostname="localhost")
tunnel.sendall(f"GET / HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n\r\n".encode())
response = b""
while chunk := tunnel.recv(65536):
response += chunk
outcome = response.split(b"\r\n", 1)[0].decode()
except TimeoutError:
outcome = "no response (client timed out)"
finally:
mitmdump.terminate()
mitmdump.wait()
print(f"TLS {sys.argv[1] if len(sys.argv) > 1 else '1.3'}: {outcome} after {time.monotonic() - start:.1f}s")Actual:
TLS 1.3: no response (client timed out) after 15.1s
TLS 1.2: HTTP/1.1 502 Bad Gateway after 0.1s
Expected: both get a 502 Bad Gateway at once, naming the TLS error.
mitmdump logs the alert and then sits there until the client times out. On TLS 1.3 it also opens the server connection a second time and gets the same alert; the flow still ends only when the client disconnects:
server: rejected client: PEER_DID_NOT_RETURN_A_CERTIFICATE
TLS Error: [('SSL routines', '', 'tlsv13 alert certificate required')]
server disconnect localhost:<port>
server connect localhost:<port>
server: rejected client: PEER_DID_NOT_RETURN_A_CERTIFICATE
TLS Error: [('SSL routines', '', 'tlsv13 alert certificate required')]
GET https://localhost:<port>/
<< Client disconnected.
On TLS 1.2 the same rejection fails the handshake, so it never reaches this path:
Server TLS handshake failed. OpenSSL Error([('SSL routines', '', 'tls alert handshake failure')])
<< OpenSSL Error([('SSL routines', '', 'tls alert handshake failure')])
This reproduces on 12.2.3. mitmproxy/proxy/layers/tls.py is unchanged between that tag and main at 7735597.
Cause
The cause is in mitmproxy/proxy/layers/tls.py, in TLSLayer:
receive_datacatches theSSL.Errorthatrecv()raises for the alert,
logsTLS Error: …, and breaks out of its loop. Unlike theZeroReturnError
(close_notify) branch, it neither clearsCAN_READnor sends
ConnectionClosedto the child layer. The comment there already describes
the same situation on the client side (Firefox on Android sending an alert
after completing the handshake).- OpenSSL records a received fatal alert as
SSL_RECEIVED_SHUTDOWN:
tls.get_shutdown()is2right after the alert. - When the server then closes the TCP connection,
receive_closesees
RECEIVED_SHUTDOWN. It assumes it "already dispatched a ConnectionClosed to
the child layer", which is only true after a close_notify, and swallows the
close.
So the child (HTTP) layer is never told that the server connection is gone. The
flow ends only when the client disconnects.
System Information
Mitmproxy: 12.2.3
Python: 3.14.7
OpenSSL: OpenSSL 4.0.1 9 Jun 2026
Platform: macOS-15.7.9-arm64-arm-64bit-Mach-O
Checklist
- This bug affects the latest mitmproxy release.
Activity
choksi2212 commentedon Oct 18, 2026
Confirming I can reproduce this with the attached script (1.3 hangs, 1.2 returns the 502 naming the OpenSSL error) — the root cause is that a fatal alert after
tls_established_servernever fails the upstream connection, so the HTTP layer is left waiting on a socket that will never deliver a response.Working on a fix now: fail the upstream connection on fatal TLS alerts post-handshake so it propagates as the same
502 Bad Gatewaythe TLS 1.2 path produces. Will follow up with a PR including the alert-handling regression test.