Skip to content

Commit a4ac244

Browse files
gpsheadpicnixz
andauthored
gh-160171: Fix ssl get_unverified_chain() on resumed server sessions (GH-160172)
Include the client's certificate when the session was resumed from a ticket, and return [] instead of raising AttributeError when the client sent no certificate. ANT-2026-2RMA0IPV Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
1 parent 3387b19 commit a4ac244

4 files changed

Lines changed: 108 additions & 22 deletions

File tree

‎Doc/library/ssl.rst‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,13 +1296,21 @@ SSL sockets also have the following additional methods and attributes:
12961296
If certificate verification was disabled method acts the same as
12971297
:meth:`~SSLSocket.get_unverified_chain`.
12981298

1299+
The verified chain is not stored with a session, so the list is empty
1300+
when the session is resumed (see :attr:`~SSLSocket.session_reused`).
1301+
12991302
.. versionadded:: 3.13
13001303

13011304
.. method:: SSLSocket.get_unverified_chain()
13021305

13031306
Returns raw certificate chain provided by the other
13041307
end of the SSL channel as a list of DER-encoded bytes.
13051308

1309+
No certificates are exchanged when a session is resumed (see
1310+
:attr:`~SSLSocket.session_reused`); the list then holds what was stored
1311+
with the session, which for a session resumed from a ticket on the
1312+
server side is the peer's own certificate only.
1313+
13061314
.. versionadded:: 3.13
13071315

13081316
.. method:: SSLSocket.cipher()

‎Lib/test/test_ssl.py‎

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3017,11 +3017,19 @@ def run(self):
30173017
cert = self.sslconn.getpeercert()
30183018
self.write(repr(cert).encode("us-ascii") + b"\n")
30193019
elif stripped == b'VERIFIEDCHAIN':
3020-
certs = self.sslconn._sslobj.get_verified_chain()
3021-
self.write(len(certs).to_bytes(1, "big") + b"\n")
3020+
try:
3021+
certs = self.sslconn.get_verified_chain()
3022+
except Exception as e:
3023+
self.write(repr(e).encode("us-ascii") + b"\n")
3024+
else:
3025+
self.write(len(certs).to_bytes(1, "big") + b"\n")
30223026
elif stripped == b'UNVERIFIEDCHAIN':
3023-
certs = self.sslconn._sslobj.get_unverified_chain()
3024-
self.write(len(certs).to_bytes(1, "big") + b"\n")
3027+
try:
3028+
certs = self.sslconn.get_unverified_chain()
3029+
except Exception as e:
3030+
self.write(repr(e).encode("us-ascii") + b"\n")
3031+
else:
3032+
self.write(len(certs).to_bytes(1, "big") + b"\n")
30253033
else:
30263034
if (support.verbose and
30273035
self.server.connectionchatty):
@@ -5701,6 +5709,66 @@ def test_internal_chain_server(self):
57015709
res = s.recv(1024)
57025710
self.assertEqual(res, b'\x02\n')
57035711

5712+
def test_internal_chain_server_resumed_session(self):
5713+
# A session resumed from a ticket stores the client's certificate
5714+
# but not the chain it was sent with.
5715+
for version in ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3:
5716+
if not has_tls_version(version):
5717+
continue
5718+
with self.subTest(version=version.name):
5719+
self.check_chain_server_resumed_session(version)
5720+
5721+
def check_chain_server_resumed_session(self, version):
5722+
client_context, server_context, hostname = testing_context()
5723+
client_context.load_cert_chain(SIGNED_CERTFILE)
5724+
server_context.verify_mode = ssl.CERT_REQUIRED
5725+
client_context.minimum_version = version
5726+
client_context.maximum_version = version
5727+
5728+
server = ThreadedEchoServer(context=server_context, chatty=False)
5729+
with server:
5730+
with client_context.wrap_socket(
5731+
socket.socket(),
5732+
server_hostname=hostname
5733+
) as s:
5734+
s.connect((HOST, server.port))
5735+
s.write(b'UNVERIFIEDCHAIN\n')
5736+
res = s.recv(1024)
5737+
self.assertEqual(res, b'\x02\n')
5738+
# TLS 1.3 sends the ticket after the handshake, so only
5739+
# get the session once data was received.
5740+
session = s.session
5741+
self.assertTrue(session.has_ticket)
5742+
with client_context.wrap_socket(
5743+
socket.socket(),
5744+
server_hostname=hostname,
5745+
session=session
5746+
) as s:
5747+
s.connect((HOST, server.port))
5748+
self.assertTrue(s.session_reused)
5749+
s.write(b'UNVERIFIEDCHAIN\n')
5750+
res = s.recv(1024)
5751+
self.assertEqual(res, b'\x01\n')
5752+
# The verified chain is not stored with the session at all.
5753+
s.write(b'VERIFIEDCHAIN\n')
5754+
res = s.recv(1024)
5755+
self.assertEqual(res, b'\x00\n')
5756+
5757+
def test_internal_chain_server_no_client_cert(self):
5758+
client_context, server_context, hostname = testing_context()
5759+
server_context.verify_mode = ssl.CERT_OPTIONAL
5760+
5761+
server = ThreadedEchoServer(context=server_context, chatty=False)
5762+
with server:
5763+
with client_context.wrap_socket(
5764+
socket.socket(),
5765+
server_hostname=hostname
5766+
) as s:
5767+
s.connect((HOST, server.port))
5768+
s.write(b'UNVERIFIEDCHAIN\n')
5769+
res = s.recv(1024)
5770+
self.assertEqual(res, b'\x00\n')
5771+
57045772

57055773
class TestSSLDebug(unittest.TestCase):
57065774

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
On the server side, :meth:`ssl.SSLSocket.get_unverified_chain` now returns
2+
the client's certificate when the session was resumed from a session ticket,
3+
instead of an empty list, and returns an empty list instead of raising
4+
:exc:`AttributeError` when the client sent no certificate.

‎Modules/_ssl.c‎

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1991,31 +1991,37 @@ _ssl__SSLSocket_get_unverified_chain_impl(PySSLSocket *self)
19911991
/*[clinic end generated code: output=5acdae414e13f913 input=079f8ff5c205cb3b]*/
19921992
{
19931993
PyObject *retval;
1994+
X509 *peer = NULL;
19941995
/* borrowed reference */
1995-
/* TODO: include SSL_get_peer_certificate() for server-side sockets */
19961996
STACK_OF(X509) *chain = SSL_get_peer_cert_chain(self->ssl);
1997-
if (chain == NULL) {
1997+
/* OpenSSL does not include the peer cert in the chain for server side
1998+
* connections, so the chain alone does not tell whether the client sent
1999+
* a certificate: the chain is empty when it did not, and NULL when the
2000+
* session was resumed from a ticket (which stores the peer cert only). */
2001+
if (self->socket_type == PY_SSL_SERVER) {
2002+
peer = SSL_get_peer_certificate(self->ssl);
2003+
if (peer == NULL) {
2004+
Py_RETURN_NONE;
2005+
}
2006+
}
2007+
else if (chain == NULL) {
19982008
Py_RETURN_NONE;
19992009
}
2000-
retval = _PySSL_CertificateFromX509Stack(self->ctx->state, chain, 1);
2010+
retval = chain == NULL
2011+
? PyList_New(0)
2012+
: _PySSL_CertificateFromX509Stack(self->ctx->state, chain, 1);
20012013
if (retval == NULL) {
2014+
X509_free(peer);
20022015
return NULL;
20032016
}
2004-
/* OpenSSL does not include peer cert for server side connections */
2005-
if (self->socket_type == PY_SSL_SERVER) {
2006-
PyObject *peerobj = NULL;
2007-
X509 *peer = SSL_get_peer_certificate(self->ssl);
2008-
2009-
if (peer == NULL) {
2010-
peerobj = Py_NewRef(Py_None);
2011-
} else {
2012-
/* consume X509 reference on success */
2013-
peerobj = _PySSL_CertificateFromX509(self->ctx->state, peer, 0);
2014-
if (peerobj == NULL) {
2015-
X509_free(peer);
2016-
Py_DECREF(retval);
2017-
return NULL;
2018-
}
2017+
if (peer != NULL) {
2018+
/* consume X509 reference on success */
2019+
PyObject *peerobj = _PySSL_CertificateFromX509(self->ctx->state,
2020+
peer, 0);
2021+
if (peerobj == NULL) {
2022+
X509_free(peer);
2023+
Py_DECREF(retval);
2024+
return NULL;
20192025
}
20202026
int res = PyList_Insert(retval, 0, peerobj);
20212027
Py_DECREF(peerobj);

0 commit comments

Comments
 (0)