MITM, certificates and TLS¶
Intercept HTTPS: the proxy terminates the client's TLS with a certificate its own CA issues, and
opens its own TLS connection to the server, so filters, scripts and logs see the decrypted
requests. This page covers the CA, choosing a CA and upstream trust per client, how the ClientHello
decides what is intercepted, host rules, ALPN mirroring and the TLS versions the proxy allows. Use
it whenever you pass --mitm or withManInTheMiddle(...).
Turning interception on¶
From the command line, --mitm creates (or reuses) a CA in microproxy-ca.p12 and writes its
certificate to microproxy-ca.pem. To intercept HTTPS without errors, add that certificate to
your client's trust store.
java -jar microproxy.jar --port 8080 --mitm
curl -x http://127.0.0.1:8080 --cacert microproxy-ca.pem https://example.com/
| Flag | Meaning |
|---|---|
--mitm |
intercept HTTPS with a generated CA |
--mitm-ca <file.p12> |
the CA key store (created if missing; default ./microproxy-ca.p12); its certificate is written next to it as <name>.pem |
--mitm-ca-password <pw> |
the key store's password (default microproxy) |
--mitm-trust-all |
do not validate upstream server certificates |
From Java, give the bootstrap a MitmManager. CertificateAuthorityMitmManager issues per-host
certificates on demand (EC P-256), whose SANs copy the real server's DNS names, through a small
built-in X.509/DER encoder (org.microproxy.tls.CertificateBuilder): only the JDK is used. It
validates real servers against the JDK trust store unless given another upstream context:
CertificateAuthority ca = CertificateAuthority.loadOrCreate(
Path.of("microproxy-ca.p12"), "microproxy".toCharArray(), "MicroProxy CA");
ca.writeCertificatePem(Path.of("microproxy-ca.pem")); // for clients to trust
MicroProxy.bootstrap()
.withManInTheMiddle(new CertificateAuthorityMitmManager(ca)) // JDK trust store
// .withManInTheMiddle(new CertificateAuthorityMitmManager(ca, SslContexts.trustAll())) // --mitm-trust-all
.start();
CertificateAuthority.generate(commonName) makes a CA in memory. For servers signed by a
private CA, SslContexts.systemDefaultPlus(caCert) trusts the JDK's roots plus extra anchors
(Upstream proxies and NO_PROXY), and
SslContexts.trusting(anchors...) only the given ones. Hosts that must never be intercepted
(banks, pinned apps) are left alone with host rules or a filter.
Interception per client connection¶
The proxy hands every MitmManager call the client connection's FlowContext, with its
authenticated user (getClientDetails().getUserName()) and address (getClientAddress()). Two
ways to use it:
- One manager per tenant:
MitmManager.perConnection(flow -> ...)picks a manager when a connection is first intercepted and keeps it for that connection. Return the same instance for clients that may share server connections;nulltunnels without interception.
Map<String, MitmManager> byTenant = Map.of(
"alice", new CertificateAuthorityMitmManager(aliceCa, aliceUpstreamContext),
"bob", new CertificateAuthorityMitmManager(bobCa, bobUpstreamContext));
MicroProxy.bootstrap()
.withProxyAuthenticator(authenticator)
.withManInTheMiddle(MitmManager.perConnection(
flow -> byTenant.get(flow.getClientDetails().getUserName())))
.start();
- One manager deciding per call: override the
FlowContextoverloads,serverSslContext(host, port, flow)(upstream trust store and client certificate;flowis aFullFlowContextnaming the server and route),clientSslContextFor(connect, session, flow)(the certificate shown to the client) orconfigureServerSocket(socket, flow). They default to the methods withoutFlowContext, so existing managers work unchanged.shouldIntercept(hello, flow)decides by the client's ClientHello whether to intercept at all (see below).
An upstream context with a key (SslContexts.withKey(key, chain, trustManagers)) presents that
client certificate to servers that ask for one.
With withPoolSharedMitmConnections(true), a pooled TLS connection carries the trust and client
certificate it was made with, so the pool keeps them apart. Connections made by a manager chosen
with forConnection (which perConnection uses) are pooled under that manager and only reused by
clients given the same one. A manager overriding serverSslContext(host, port, flow) or
configureServerSocket(socket, flow) may decide differently for every client, so its server
connections are not pooled: each client connection keeps its own. Overriding only
clientSslContextFor(..., flow) does not affect pooling.
Deciding what to intercept: the ClientHello¶
With a MITM manager, a CONNECT is not intercepted blindly. The proxy connects to the server (TCP,
and through any chained proxy), answers 200, and reads what the client sends first without
consuming it, as mitmproxy does:
- A TLS ClientHello is read across as many TLS records as it spans (post-quantum key shares
make it several), up to 64 KiB and within the TLS handshake timeout, and parsed into a
ClientHello: the server name (SNI), the ALPN protocols, the TLS versions and the cipher suites, GREASE values included. The rules below decide whether to intercept. If they do, the proxy shakes hands with the server first and then with the client, replaying the bytes it read into the JDK's handshake withSSLSocketFactory.createSocket(socket, consumed, autoClose). If not, the bytes are relayed to the server first and the connection is a plain tunnel, so the client sees the real server's certificate. - Plain HTTP (a request line, as a
ws://WebSocket through a proxy sends) is served as HTTP: its requests go to theCONNECTtarget through the filters, as in an intercepted session but without TLS. Absolute-form requests andCONNECTs in it are proxy requests for that target, which the client takes for its HTTP proxy (In front of an HTTP or HTTPS proxy). With the protocol policy fieldserve_plain_http_after_connectoff (as in the LittleProxy preset,--littleproxy-compatfor HTTP/1 clients) it is tunnelled, as LittleProxy does. - Anything else is tunnelled, the bytes read so far first: other protocols, HTTP/2 with prior knowledge, and a malformed or oversized ClientHello. A server that speaks first (SMTP, SSH) is noticed while the proxy waits for the client, and its connection is tunnelled as well.
So it goes, too, for a tunnel that a client asks its own proxy for inside a session MicroProxy serves, once that proxy has opened it: everything then reaches the target through that proxy's tunnels (In front of an HTTP or HTTPS proxy).
Interception is decided in this order; the first "no" tunnels the connection untouched:
HttpFilters.proxyToServerAllowMitm()and the ignore rules against theCONNECTtarget, when theCONNECTarrives. Nothing is read from the client then, as before.- With the ClientHello: the host rules against the
CONNECTtarget and the SNI; a client whose ALPN offers no protocol the proxy speaks (onlyacme-tls/1, say; with a TCP interceptor, such clients are intercepted, exceptacme-tls/1, and their sessions relayed as raw TCP); thenMitmManager.shouldIntercept(clientHello, flow)andHttpFilters.proxyToServerAllowMitm(clientHello).
MicroProxy.bootstrap()
.withManInTheMiddle(new CertificateAuthorityMitmManager(ca))
.withIgnoreHosts("\\.bank\\.example:443$") // --ignore-hosts, ignore_hosts
.withFiltersSource((request, flow) -> HttpFilters.builder()
.allowMitmFor(hello -> !"pinned.example.com".equals(hello.sni()))
.build())
.start();
FlowContext.getClientHello() then returns the ClientHello to the session's requests, and to
MitmManager.clientSslContextFor(connect, session, flow). Scripts get on_client_hello and
ctx.client_hello (see Scripting).
The same applies to a CONNECT stream of an HTTP/2 client (CONNECT and
WebSockets). A CONNECT inside an intercepted session, or inside a plain
HTTP one, goes to the session's server instead (In front of an HTTP or HTTPS
proxy): the tunnel it opens is intercepted without reading
its ClientHello, unless the host rules ignore its target.
Server TLS failures¶
When the proxy runs its handshake with the server is the protocol policy
field mitm_server_handshake (MitmServerHandshake), which a request's filters can override
(HttpFilters.proxyToServerMitmHandshake(), HttpFilters.builder().mitmServerHandshake(...)),
after proxy authentication and clientToProxyRequest on the CONNECT, so the choice can depend
on the authenticated user:
MitmServerHandshake |
The server's handshake | When every route's handshake fails |
|---|---|---|
DEFERRED (the default) |
after the CONNECT is answered and the client's ClientHello read, mirroring its ALPN protocols (and its SNI for a CONNECT to an IP address); the ClientHello also decides first whether to intercept at all |
the client's handshake completes with a certificate made from the host name, and its first request in the session is answered 502 (ProxyFailure.TlsFailed, through proxyToServerFailure and the FailureResponder), as mitmproxy answers; a client whose handshake cannot complete is disconnected |
EAGER (LittleProxy's) |
before the CONNECT is answered, sending the CONNECT's host (or the request's TlsServerIdentity) as SNI and offering the proxy's own ALPN protocols (h2 and http/1.1 with HTTP/2 to servers), since the ClientHello has not come yet |
the CONNECT is answered 502 (ProxyFailure.TlsFailed), and no tunnel opens |
Either way a failed handshake is a failed attempt at its route: the next chained proxy (or the
direct fallback) the ChainedProxyManager offered is tried, after the
backoff wait, as when the connection itself fails;
only the last failure is answered, with UF and, after several routes, URX among the
response flags. The retry without
SNI below and the request's handshake timeout (proxyToServerTlsHandshakeTimeout) apply to
each attempt.
EAGER trades the mirroring away. The server's handshake offers the proxy's own protocols: when
the server chooses HTTP/2 and the client then offers only HTTP/1.1, the client's requests go on a
second connection that offers what it offered (the first is kept for HTTP/2 requests). A
server is never offered the protocols of a client that speaks no HTTP, nor the SNI of a client
that CONNECTs to an IP address, and a server that speaks no TLS and never answers the proxy's
ClientHello holds the CONNECT until the TLS handshake timeout, then answers it 502 (as in
LittleProxy). A session that the
ClientHello then decides not to intercept (host rules, MitmManager.shouldIntercept,
proxyToServerAllowMitm(ClientHello)) is tunnelled over a new connection; the one made for the
handshake is closed. LittleProxy runs the handshake first because its
MitmManager.clientSslEngineFor(HttpRequest, SSLSession) makes the client's certificate from the
server's session; proxy authentication happens on the CONNECT in both modes. Transparent TLS
connections, whose ClientHello the proxy has read already, are always deferred.
A server that turns out not to speak TLS gets the client's bytes tunnelled, in both modes.
Unreachable servers fail the CONNECT with 502 in both, since it is connected before it is
answered. With DEFERRED, proxyToServerConnectionSSLHandshakeStarted comes after the
CONNECT's 200, and the CONNECT exchange completes (proxyToClientResponseSent,
responseCompleted) once the server handshake is over, so its timings include that handshake.
Every server TLS failure reaches filters, the FailureResponder and the access log as a
ProxyFailure.TlsFailed whose cause() is the JDK's own exception with its whole cause chain:
for a revoked certificate, the CertPathValidatorException with reason REVOKED is in it, for
an expired one the CertificateExpiredException. TlsFailed.reason() reads the chain:
reason() |
When |
|---|---|
CERTIFICATE_REVOKED, REVOCATION_STATUS_UNKNOWN |
revocation checking (a PKIXRevocationChecker in the trust manager's parameters) found the certificate revoked, or could not tell |
CERTIFICATE_EXPIRED, CERTIFICATE_NOT_YET_VALID |
a certificate of the chain is out of its validity period |
CERTIFICATE_UNTRUSTED |
no path to a trusted anchor, or another validation check failed |
HOSTNAME_MISMATCH |
the certificate is trusted but not valid for the host it was checked against |
ALERT_RECEIVED |
the server ended the handshake with an alert; alert() names it (handshake_failure, unrecognized_name, ...) |
TIMEOUT |
the handshake overran --tls-handshake-timeout |
OTHER |
anything else (no common version or cipher suite, the connection closed, ...) |
The access log's %UPSTREAM_TRANSPORT_FAILURE_REASON% is that reason's name and the innermost
cause's message: certificate_revoked: Certificate has been revoked, reason: ....
Some servers answer a server name (SNI) they do not recognize with a fatal unrecognized_name
alert (RFC 6066 section 3), even for names they serve. withTlsRetryWithoutSni(true)
(--tls-retry-without-sni, tls_retry_without_sni=true) tries such a server once more, on a new
connection, without SNI, as the JDK's HttpsURLConnection once did and LittleProxy forks still
do. It is off by default, so that the misconfiguration shows (browsers and the JDK's HttpClient
do not retry), and on by default for HTTP/1 clients with withLittleProxyCompatibility(). It is
a protocol policy field, tls_retry_without_sni, so it can be set for
HTTP/1, HTTP/2 or HTTP/3 clients alone too. The retry leaves out the
SNI extension only: the certificate is validated with the same trust and checked against the same
host, so leaving out a name that authenticates nothing weakens no check; a server that then
presents a certificate for another name still fails, with HOSTNAME_MISMATCH. Each retry is
logged at INFO and counted in the cluster's ssl.retry_without_sni (the rejected handshake in
its ssl.connection_error). Nothing is remembered: each new connection to such a server pays for
the extra handshake. It applies to servers (intercepted sessions, TLS origination, a reverse
proxy's https:// upstream), not to TLS chained proxies, and not to HTTP/3.
Server name and verification host¶
The proxy sends the request's host as the server name (SNI) and checks the server's certificate
against it; for a CONNECT to an IP address it sends the server name the client's ClientHello
named. A route that dials somewhere else than the name the service is known by, an IP address
or an internal load balancer (proxyToServerResolutionStarted, a resolver, a reverse proxy's
upstream, a chained proxy), can name both itself: HttpFilters.proxyToServerTlsIdentity returns
a TlsServerIdentity with the server name and the verification host, null keeping the default.
HttpFilters filters = HttpFilters.builder()
.resolveWith(hostAndPort -> hostAndPort.startsWith("api.example.com:")
? new InetSocketAddress("10.0.0.7", 443) : null)
.tlsIdentity(hostAndPort -> hostAndPort.startsWith("api.example.com:")
? TlsServerIdentity.of("api.example.com") : null)
.build();
TlsServerIdentity.of(name) sends name and checks the certificate against it;
TlsServerIdentity.of(name, verificationHost) checks it against another host (or IP address)
than the one sent; TlsServerIdentity.of(null, verificationHost) keeps the default server name.
The certificate is validated with the MITM manager's trust as always; only the names change. When
the two names differ, the JDK's own check would accept a certificate valid for either, so the
proxy checks the verification host itself, right after the handshake and before the connection
is used, against the certificate's subject alternative names (DNS names, or IP addresses for an
IP address; the common name is not consulted). A context that trusts every server
(--mitm-trust-all) checks no names, as before.
The hook is asked for every request that reaches its server over TLS, before a connection is
chosen, and for an intercepted CONNECT before the proxy's handshake with its target, with the
server's host:port. The identity is part of what keeps server connections apart: one made with
an identity serves only requests with the same identity, never reused, pooled or coalesced
(HTTP/2 coalescing, RFC 9113 section 9.1.1) for another, and requests
with an identity do not go over HTTP/3. The Starlark scripting layer has no hook for it yet.
Ignoring and allowing hosts¶
withIgnoreHosts(...) (--ignore-hosts, ignore_hosts) and withAllowHosts(...)
(--allow-hosts, allow_hosts) work as mitmproxy's options of the same names. Each pattern is a
regular expression searched for (not matched whole), ignoring case, in the connection's host:port
names: the CONNECT target, and the client's SNI with the same port. An ignored connection is
tunnelled untouched. With allow patterns, only connections that one of them finds are
intercepted, and the ignore patterns still apply to those.
| Pattern | Ignores |
|---|---|
example\.com |
example.com:443, www.example.com:443, example.com.evil.test:443 |
^(.+\.)?example\.com:443$ |
example.com and its subdomains, on port 443 only |
^10\. |
CONNECTs to 10.x.x.x addresses, whatever their SNI |
On the command line each flag takes one pattern and may be repeated; in a properties file the
patterns are separated by commas (commas inside {...} belong to the pattern). A CONNECT whose
target an ignore pattern finds is tunnelled without reading its ClientHello at all. The rules
apply to TLS and QUIC only: plain HTTP requests are proxied as usual. For QUIC the names are the
server and the SNI on the server's port: transparent QUIC's flows, and, with
--masque-intercept-quic, the QUIC inside CONNECT-UDP flows, an
ignored one being relayed as any UDP flow is.
ALPN mirroring¶
When intercepting, the server is offered only the ALPN protocols the client offered, of those the
proxy speaks and has enabled: http/1.1, http/1.0, and h2 only with
HTTP/2 to servers on. The client's own handshake then mirrors the server's
choice when that choice tells what the server speaks, that is, when the server was offered h2.
| Client offers | Server offered | Server picks | Client gets |
|---|---|---|---|
http/1.1 |
http/1.1 |
http/1.1 |
http/1.1: never HTTP/2 upstream |
h2, http/1.1 |
h2, http/1.1 |
h2 |
h2 (unless --no-http2): HTTP/2 end to end |
h2, http/1.1 |
h2, http/1.1 |
http/1.1 |
http/1.1, the same as the server |
h2, http/1.1 (with --no-http2-upstream) |
http/1.1 |
http/1.1 |
h2 unless --no-http2: the proxy translates |
| no ALPN | h2, http/1.1, or none with --no-http2-upstream |
either | no protocol (HTTP/1.1) |
This follows mitmproxy's order (tlsconfig.py: the server's handshake first, with the client's
offers, then the client's with the server's choice). Two differences: a client that sends no ALPN
keeps the proxy's old offer, so HTTP/1.1 clients without ALPN (such as the JDK's HttpClient)
still reach HTTP/2 servers; and where the server was not offered h2,
clients still get HTTP/2 unless --no-http2 turns it off. Without a server connection of its own, the
session mirrors nothing: an existing HTTP/2 connection the session shares (a client that did not
offer h2 gets none of it, and its requests connect with its own offers), a reused pooled
connection, or offline interception. The session's requests that connect later offer the same
protocols.
TLS protocol versions¶
Every TLS socket the proxy creates allows only TLSv1.3 and TLSv1.2 by default: the TLS
listener (withSslContextSource), both sides of an intercepted session (towards the client and
towards the server) and connections to TLS chained proxies. withTlsProtocols(...) (or
tls_protocols=..., --tls-protocols ...) changes the list:
MicroProxy.bootstrap().withTlsProtocols("TLSv1.3").start(); // TLS 1.3 only
MicroProxy.bootstrap().withTlsProtocols().start(); // each SSLContext's own defaults
- Per socket: each socket enables the listed versions its
SSLContextsupports, in the order given. If it supports none of them, the handshake fails with an error naming both lists (aTlsFailedtowards servers, a failed handshake towards clients). - Order: the versions are set right after the socket is created, before
SslContextSource.configure,MitmManager.configureServerSocketandChainedProxy.configurerun. A hook that sets its own protocols therefore wins. - The JDK still applies
jdk.tls.disabledAlgorithms, which turns off TLS 1.1 and older, whatever is listed here. - Resumption over TCP is the JDK's. TLS over TCP (HTTPS, HTTP/2, intercepted sessions, TLS
chained proxies) runs on the JDK's
SSLEngine, which resumes sessions on its own: eachSSLContextcaches sessions (SSLSessionContext,javax.net.ssl.sessionCacheSize,jdk.tls.server.enableSessionTicketExtensionfor stateless TLS 1.3 tickets) and offers them when it connects to the same server again. The proxy adds nothing to it, and the JDK sends and accepts no 0-RTT data. QUIC's TLS is thequicmodule's own, with its own tickets and 0-RTT (Session resumption and 0-RTT). - A server that refuses the proxy's versions with a
protocol_versionalert gets the requests inside the session a502(TlsFailed; see server TLS failures). One that just closes the connection on the proxy'sClientHellolooks like a server that does not speak TLS, and the client's bytes are tunnelled without interception, as for any non-TLS server.