Skip to content

fix: fall back to PROXY V2 SSL TLVs when the inner TLS has no peer cert - #235

Merged
zmstone merged 1 commit into
masterfrom
261016-pp2-tlv-fallback-when-inner-not-mtls
Oct 16, 2026
Merged

zmstone merged 1 commit into
masterfrom
261016-pp2-tlv-fallback-when-inner-not-mtls

Conversation

@zmstone

@zmstone zmstone commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Problem

A proxied connection to an SSL listener has two places a client certificate can
come from, and which one holds it depends on where TLS was terminated.

  • A proxy that terminates mTLS (HAProxy with send-proxy-v2-ssl-cn) forwards the
    client's details in the PROXY V2 SSL TLVs. The inner handshake is one-way, or
    carries the proxy's own certificate.
  • A TCP-passthrough proxy (NLB, Traefik tls: passthrough) terminates nothing. It
    sends no SSL TLVs, and the client certificate is on the inner ssl_socket.

PR #218 made peercert/1, peer_cert_subject/1 and peer_cert_common_name/1
delegate to the inner ssl_socket unconditionally when the listener is SSL. That
fixed the passthrough deployment and broke the terminating one, which had worked
before, because the TLV clause became unreachable.

Deployments

Deployment Where the client cert actually is Before #218 After #218 With this PR
A Passthrough proxy (NLB, Traefik tls: passthrough) + TLS listener, client mTLS to EMQX inner TLS session; no SSL TLVs sent broken works works
B Proxy terminates mTLS + plain TCP to EMQX + TCP listener PP2 SSL TLVs works works works
C Proxy terminates mTLS + re-encrypt to EMQX + TLS listener, one-way inner PP2 SSL TLVs works broken works
D As C, but the proxy also presents its own client cert on the inner hop PP2 SSL TLVs; the inner cert is the proxy's works wrong identity, reports the proxy as the client works

Row D does not fail loudly today. It reports HAProxy as every client.

Rows C and D are a 5.x to 6.x upgrade regression rather than a long-standing gap.
#218 first shipped in esockd 5.16.2, and the EMQX lines pin either side of it:

EMQX branch esockd behaviour
dev-58 5.13.0 reads the TLVs
dev-510 5.14.0 reads the TLVs
dev-60 5.17.7 reads the inner socket
dev-63 5.17.8 reads the inner socket

Change

Read the SSL TLVs first. Use the inner socket only when the header carries no
certificate information. Every row above then works, which neither predecessor
achieved.

The PROXY header comes from the hop closest to the client, so when it carries
certificate information it describes the client. An inner certificate on a
re-encrypted hop belongs to the proxy, not the client. Enabling proxy_protocol
on a listener already declares the upstream trusted — the peer address comes from
the same header and is already used that way — so this adds no new trust
assumption, and no new option: the listener either trusts the header or does not
enable the feature.

One predicate, pp2_ssl_info/1, picks the source for all three accessors.
Deciding per accessor would let peercert/1 report the proxy's certificate while
peer_cert_common_name/1 reports the client's, because a proxy can send the SSL
TLV without the CN sub-TLV.

peersni/1 takes the same precedence, for the same reason: the authority TLV is
the name the client asked for, the inner SNI is whatever the proxy chose when it
opened the re-encrypted hop.

Tests

Three cases pin rows A, C and D, so none can be traded for another again:

  • t_peercert_proxy_ssl/1 (from fix: read peercert from inner ssl_socket on proxied SSL listeners #218) — row A. Passthrough proxy, PROXY V1, no
    TLVs. The inner socket is the only source. Unchanged and still passing.
  • t_peercert_proxy_ssl_pp2_fallback/1 — row C. Terminating proxy, one-way inner
    TLS, PROXY V2 with a PP2_SUBTYPE_SSL_CN. The TLVs are the only source.
  • t_peercert_proxy_ssl_pp2_wins_over_inner/1 — row D. Both populated: mTLS on
    the inner hop and SSL TLVs in the header. Pins the precedence this PR chooses.

Negative controls: the row C case fails on master with
{false,undefined,undefined}; the row D case fails against inner-first
precedence.

Full suite: 240 passed, 3 skipped (pre-existing, remote TCP test server absent).

@zmstone
zmstone force-pushed the 261016-pp2-tlv-fallback-when-inner-not-mtls branch from 147fae0 to c854f57 Compare October 16, 2026 07:12
@zmstone
zmstone marked this pull request as ready for review October 16, 2026 07:14
A proxied connection to an SSL listener has two places a client
certificate can come from, and which one holds it depends on where TLS
was terminated.

A proxy that terminates mTLS forwards the client's details in the PROXY
V2 SSL TLVs. That hop is the one closest to the client, so the TLVs
describe the client. The inner certificate, when the re-encrypted hop
presents one at all, belongs to the proxy. A TCP-passthrough proxy
terminates nothing, sends no SSL TLVs, and leaves the client
certificate on the inner ssl_socket.

PR #218 made peercert/1, peer_cert_subject/1 and peer_cert_common_name/1
delegate to the inner ssl_socket unconditionally. That fixed the
passthrough deployment and broke the terminating one, which had worked
before, because the TLV clause became unreachable. The break shows up as
a version split: EMQX 5.8 and 5.10 pin esockd 5.13.0 and 5.14.0 and
still read the TLVs, while 6.0 and later pin 5.17.x and read the inner
socket, so a HAProxy deployment loses the client CN on upgrade to 6.x.
Where the proxy also presents its own certificate on the inner hop, the
current code does not fail at all: it reports the proxy as the client.

Read the SSL TLVs first and fall back to the inner socket only when the
header carries no certificate information. Enabling proxy_protocol on a
listener already declares the upstream trusted; the peer address comes
from the same header and is already used that way. No new option: the
listener either trusts the header or does not enable the feature.

One predicate, pp2_ssl_info/1, decides the source for all three
accessors. Deciding per accessor would let peercert/1 report the proxy's
certificate while peer_cert_common_name/1 reports the client's, because
a proxy can send the SSL TLV without the CN sub-TLV.

peersni/1 takes the same precedence for the same reason. The authority
TLV is the name the client asked for; the inner SNI is whatever the
proxy chose when it opened the re-encrypted hop.

Tests cover the three deployments so none can be traded for another
again: the existing t_peercert_proxy_ssl for a passthrough proxy with no
TLVs, t_peercert_proxy_ssl_pp2_fallback for a terminating proxy with a
one-way inner hop, and t_peercert_proxy_ssl_pp2_wins_over_inner for both
sources populated. Negative controls: the second fails on the parent
commit with {false,undefined,undefined}, the third fails against
inner-first precedence.
@zmstone
zmstone force-pushed the 261016-pp2-tlv-fallback-when-inner-not-mtls branch from c854f57 to d083349 Compare October 16, 2026 07:56
@zmstone
zmstone merged commit 4c6983c into master Oct 16, 2026
7 checks passed
@zmstone
zmstone deleted the 261016-pp2-tlv-fallback-when-inner-not-mtls branch October 16, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants