[
https://issues.apache.org/jira/browse/THRIFT-6233?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Sylwester Lachiewicz updated THRIFT-6233:
-----------------------------------------
Description:
{{TSSLServerSocket}} calls its default {{validate_callback}} with the client
certificate and the address the connection arrived from, whenever {{cert_reqs}}
asks for a certificate. Which function that default is depends on the
interpreter:
* Python 3.12 and later: {{sslcompat.match_peer_ipaddress}}, which matches IP
subjectAltName records and, since
[THRIFT-6201|https://issues.apache.org/jira/browse/THRIFT-6201], treats
{{::ffff:127.0.0.1}} and {{127.0.0.1}} as the same address.
* Python 3.7 to 3.11: {{ssl.match_hostname}}, deprecated since 3.7, which
compares addresses exactly and falls back to the commonName when the
certificate has no subjectAltName.
So the same client certificate is accepted or refused depending on the server's
Python. A dual-stack listener ({{host=None}} binds {{::}} with
{{IPV6_V6ONLY=0}}) reports an IPv4 client as {{::ffff:127.0.0.1}}; a
certificate carrying {{IP Address:127.0.0.1}} is accepted on 3.12 and refused
on 3.10:
{noformat}
$ python3.10 -c "import ssl; ssl.match_hostname(cert, '::ffff:127.0.0.1')"
ssl.SSLCertVerificationError: hostname '::ffff:127.0.0.1' doesn't match either
of '127.0.0.1', '0:0:0:0:0:0:0:1', 'localhost'
{noformat}
[THRIFT-3660|https://issues.apache.org/jira/browse/THRIFT-3660] worked around
exactly this in 2016 by adding the mapped address to
{{test/keys/client_v3.crt}};
[THRIFT-6275|https://issues.apache.org/jira/browse/THRIFT-6275] has to remove
that entry again because Go 1.27 rejects certificates that carry one, which
leaves servers on Python 3.11 or earlier exposed to the mismatch.
h3. Change
* {{TSSLServerSocket}} defaults {{validate_callback}} to
{{match_peer_ipaddress}} on every Python version. The server only ever matches
{{addr[0]}}, an IP address, which is that function's contract. The check stays
on by default; a certificate that does not list the peer address is still
refused.
* On 3.7 to 3.11 this means IPv4-mapped peers match as they do on 3.12, and the
commonName fallback of {{ssl.match_hostname}} no longer applies: a client
certificate has to carry the peer address as an IP subjectAltName. That is the
documented behaviour on 3.12 already, and the fallback only ever matched a
certificate whose commonName is literally the peer address string.
* The default matches IP subjectAltName records only. A DNS subjectAltName is
not matched: the server has no name for the client, and a reverse lookup of the
peer address is controlled by the client's network. Which subjects may connect
is the application's policy and goes through a caller-supplied
{{validate_callback}}, which receives the full {{getpeercert()}} dictionary;
the docstring gets an example that refuses on the subject.
* {{TSSLSocket}} on the client side keeps {{_match_hostname}}; OpenSSL matches
the server name during the handshake through {{check_hostname}} on every
supported version.
* The {{backports.ssl_match_hostname}} branch in
{{sslcompat._optional_dependencies}}, the {{ValueError}} in
{{TSSLServerSocket.__init__}} that names it, and the {{setup.py}} dependency
go, which is [THRIFT-6265|https://issues.apache.org/jira/browse/THRIFT-6265]:
the library requires Python 3 and CI builds from 3.10, so that branch cannot
run.
* Tests drive {{TSSLServerSocket.accept()}} under the running interpreter: a
certificate with the peer address is accepted, one without is refused, a client
that arrives as an IPv4-mapped address is accepted against a certificate
carrying the plain IPv4 address, so the 3.10 and 3.11 rows of the matrix
exercise the reduction, and a custom callback that refuses on the subject shows
the policy path.
*
[lib/py/README.md|https://github.com/apache/thrift/blob/master/lib/py/README.md]
notes for 0.25.0 that the server-side check is the same on every Python
version and covers IP subjectAltName records only, with the commonName note for
servers that relied on {{ssl.match_hostname}} before.
_This issue was created with AI assistance._
was:
{{TSSLServerSocket}} calls its default {{validate_callback}} with the client
certificate and the address the connection arrived from, and on Python 3.12 and
later that default ({{sslcompat.match_peer_ipaddress}}) accepts the certificate
only when the address appears among its IP subjectAltName records. Python is
the only binding that does this by default: the C++ and D access managers are
installed on client sockets only, and the other bindings with a TLS server
leave client-certificate policy to the TLS configuration or to a callback the
application supplies.
A client checks the server's certificate against the name it meant to reach. A
server has no such reference for its clients: the source address is not
something the client asserts, and NAT, proxies, load balancers and container
networking rewrite it routinely, so a client certificate often cannot carry the
address the server will see. Which certificates may connect is the
application's policy, expressed through a {{validate_callback}} that looks at
the subject or the subjectAltName, or through a CA that issues only to the
clients meant to connect. THRIFT-3599 added the check in 0.10.0 so that a
server would not accept just any certificate its CA had signed; the peer
address is a poor stand-in for that. It only concerns servers that request
client certificates, since {{cert_reqs}} defaults to {{CERT_NONE}}. As master
stands, 0.25.0 would start refusing clients on Python 3.12 and later whose
certificates do not list the address the server sees.
Change:
* {{TSSLServerSocket}} defaults {{validate_callback}} to {{None}}. OpenSSL
still verifies the client certificate against {{ca_certs}} whenever
{{cert_reqs}} asks for one.
* {{thrift.transport.sslcompat.match_peer_ipaddress}} stays as the documented
way to opt back in: {{validate_callback=match_peer_ipaddress}}.
* {{TSSLSocket}} and {{sslcompat._match_hostname}} are unchanged; the client
path still relies on them.
* The {{_match_has_ipaddress}} check that raised {{ValueError}} in
{{TSSLServerSocket.__init__}} goes, since it only made sense while the matcher
was the default.
*
[lib/py/README.md|https://github.com/apache/thrift/blob/master/lib/py/README.md]
(Breaking Changes, 0.25.0) and
[test/keys/README.md|https://github.com/apache/thrift/blob/master/test/keys/README.md]
are updated, with a note for servers on Python 3.11 or earlier that relied on
the check: the opt-in covers addresses listed as IP subjectAltName records, not
the commonName fallback that {{ssl.match_hostname}} also applied.
* Tests go through {{TSSLServerSocket.accept()}}: {{client.crt}}, trusted by
the server but carrying no IP subjectAltName, is accepted by default and
refused with the opt-in; {{client_v3.crt}} is accepted with it.
Follow-up agreed on [PR #3818|https://github.com/apache/thrift/pull/3818],
which keeps the opt-in matcher treating {{::ffff:127.0.0.1}} and {{127.0.0.1}}
as the same address
([THRIFT-6201|https://issues.apache.org/jira/browse/THRIFT-6201]).
Summary: TSSLServerSocket peer-address check depends on the Python
version (was: Make the peer-address check on TSSLServerSocket opt-in)
> TSSLServerSocket peer-address check depends on the Python version
> -----------------------------------------------------------------
>
> Key: THRIFT-6233
> URL: https://issues.apache.org/jira/browse/THRIFT-6233
> Project: Thrift
> Issue Type: Improvement
> Components: Python - Library
> Reporter: Sylwester Lachiewicz
> Priority: Major
> Fix For: 0.25.0
>
> Time Spent: 20m
> Remaining Estimate: 0h
>
> {{TSSLServerSocket}} calls its default {{validate_callback}} with the client
> certificate and the address the connection arrived from, whenever
> {{cert_reqs}} asks for a certificate. Which function that default is depends
> on the interpreter:
> * Python 3.12 and later: {{sslcompat.match_peer_ipaddress}}, which matches IP
> subjectAltName records and, since
> [THRIFT-6201|https://issues.apache.org/jira/browse/THRIFT-6201], treats
> {{::ffff:127.0.0.1}} and {{127.0.0.1}} as the same address.
> * Python 3.7 to 3.11: {{ssl.match_hostname}}, deprecated since 3.7, which
> compares addresses exactly and falls back to the commonName when the
> certificate has no subjectAltName.
> So the same client certificate is accepted or refused depending on the
> server's Python. A dual-stack listener ({{host=None}} binds {{::}} with
> {{IPV6_V6ONLY=0}}) reports an IPv4 client as {{::ffff:127.0.0.1}}; a
> certificate carrying {{IP Address:127.0.0.1}} is accepted on 3.12 and refused
> on 3.10:
> {noformat}
> $ python3.10 -c "import ssl; ssl.match_hostname(cert, '::ffff:127.0.0.1')"
> ssl.SSLCertVerificationError: hostname '::ffff:127.0.0.1' doesn't match
> either of '127.0.0.1', '0:0:0:0:0:0:0:1', 'localhost'
> {noformat}
> [THRIFT-3660|https://issues.apache.org/jira/browse/THRIFT-3660] worked around
> exactly this in 2016 by adding the mapped address to
> {{test/keys/client_v3.crt}};
> [THRIFT-6275|https://issues.apache.org/jira/browse/THRIFT-6275] has to remove
> that entry again because Go 1.27 rejects certificates that carry one, which
> leaves servers on Python 3.11 or earlier exposed to the mismatch.
> h3. Change
> * {{TSSLServerSocket}} defaults {{validate_callback}} to
> {{match_peer_ipaddress}} on every Python version. The server only ever
> matches {{addr[0]}}, an IP address, which is that function's contract. The
> check stays on by default; a certificate that does not list the peer address
> is still refused.
> * On 3.7 to 3.11 this means IPv4-mapped peers match as they do on 3.12, and
> the commonName fallback of {{ssl.match_hostname}} no longer applies: a client
> certificate has to carry the peer address as an IP subjectAltName. That is
> the documented behaviour on 3.12 already, and the fallback only ever matched
> a certificate whose commonName is literally the peer address string.
> * The default matches IP subjectAltName records only. A DNS subjectAltName is
> not matched: the server has no name for the client, and a reverse lookup of
> the peer address is controlled by the client's network. Which subjects may
> connect is the application's policy and goes through a caller-supplied
> {{validate_callback}}, which receives the full {{getpeercert()}} dictionary;
> the docstring gets an example that refuses on the subject.
> * {{TSSLSocket}} on the client side keeps {{_match_hostname}}; OpenSSL
> matches the server name during the handshake through {{check_hostname}} on
> every supported version.
> * The {{backports.ssl_match_hostname}} branch in
> {{sslcompat._optional_dependencies}}, the {{ValueError}} in
> {{TSSLServerSocket.__init__}} that names it, and the {{setup.py}} dependency
> go, which is [THRIFT-6265|https://issues.apache.org/jira/browse/THRIFT-6265]:
> the library requires Python 3 and CI builds from 3.10, so that branch cannot
> run.
> * Tests drive {{TSSLServerSocket.accept()}} under the running interpreter: a
> certificate with the peer address is accepted, one without is refused, a
> client that arrives as an IPv4-mapped address is accepted against a
> certificate carrying the plain IPv4 address, so the 3.10 and 3.11 rows of the
> matrix exercise the reduction, and a custom callback that refuses on the
> subject shows the policy path.
> *
> [lib/py/README.md|https://github.com/apache/thrift/blob/master/lib/py/README.md]
> notes for 0.25.0 that the server-side check is the same on every Python
> version and covers IP subjectAltName records only, with the commonName note
> for servers that relied on {{ssl.match_hostname}} before.
> _This issue was created with AI assistance._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)