Skip to content

Request hangs when a TLS 1.3 server rejects the client certificate after the handshake #8486

Open

Description

@Hunterlige

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:

tls13_alert_repro.py

  1. Save the script below as tls13_alert_repro.py. It needs cryptography, and
    mitmdump on PATH. It starts a local HTTPS server that requires a client
    certificate, then stock mitmdump with no addons and no client_certs, which
    trusts the server's CA through ssl_verify_upstream_trusted_ca. It sends one
    request through it.
  2. Run python tls13_alert_repro.py 1.3.
  3. Run python tls13_alert_repro.py 1.2 for 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:

  1. receive_data catches the SSL.Error that recv() raises for the alert,
    logs TLS Error: …, and breaks out of its loop. Unlike the ZeroReturnError
    (close_notify) branch, it neither clears CAN_READ nor sends
    ConnectionClosed to 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).
  2. OpenSSL records a received fatal alert as SSL_RECEIVED_SHUTDOWN:
    tls.get_shutdown() is 2 right after the alert.
  3. When the server then closes the TCP connection, receive_close sees
    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

Activity

  1. choksi2212 commented on Oct 18, 2026

    @choksi2212

    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_server never 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 Gateway the TLS 1.2 path produces. Will follow up with a PR including the alert-handling regression test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions