[ 
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)

Reply via email to