Update cheroot-8.5.2

This commit is contained in:
JonnyWong16 2021-10-14 21:14:02 -07:00
commit 182e5f553e
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
25 changed files with 2171 additions and 602 deletions

View file

@ -1,7 +1,7 @@
"""
A library for integrating Python's builtin ``ssl`` library with Cheroot.
A library for integrating Python's builtin :py:mod:`ssl` library with Cheroot.
The ssl module must be importable for SSL functionality.
The :py:mod:`ssl` module must be importable for SSL functionality.
To use this module, set ``HTTPServer.ssl_adapter`` to an instance of
``BuiltinSSLAdapter``.
@ -10,6 +10,10 @@ To use this module, set ``HTTPServer.ssl_adapter`` to an instance of
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import socket
import sys
import threading
try:
import ssl
except ImportError:
@ -27,13 +31,12 @@ import six
from . import Adapter
from .. import errors
from .._compat import IS_ABOVE_OPENSSL10
from .._compat import IS_ABOVE_OPENSSL10, suppress
from ..makefile import StreamReader, StreamWriter
from ..server import HTTPServer
if six.PY2:
import socket
generic_socket_error = socket.error
del socket
else:
generic_socket_error = OSError
@ -49,37 +52,159 @@ def _assert_ssl_exc_contains(exc, *msgs):
return any(m.lower() in err_msg_lower for m in msgs)
def _loopback_for_cert_thread(context, server):
"""Wrap a socket in ssl and perform the server-side handshake."""
# As we only care about parsing the certificate, the failure of
# which will cause an exception in ``_loopback_for_cert``,
# we can safely ignore connection and ssl related exceptions. Ref:
# https://github.com/cherrypy/cheroot/issues/302#issuecomment-662592030
with suppress(ssl.SSLError, OSError):
with context.wrap_socket(
server, do_handshake_on_connect=True, server_side=True,
) as ssl_sock:
# in TLS 1.3 (Python 3.7+, OpenSSL 1.1.1+), the server
# sends the client session tickets that can be used to
# resume the TLS session on a new connection without
# performing the full handshake again. session tickets are
# sent as a post-handshake message at some _unspecified_
# time and thus a successful connection may be closed
# without the client having received the tickets.
# Unfortunately, on Windows (Python 3.8+), this is treated
# as an incomplete handshake on the server side and a
# ``ConnectionAbortedError`` is raised.
# TLS 1.3 support is still incomplete in Python 3.8;
# there is no way for the client to wait for tickets.
# While not necessary for retrieving the parsed certificate,
# we send a tiny bit of data over the connection in an
# attempt to give the server a chance to send the session
# tickets and close the connection cleanly.
# Note that, as this is essentially a race condition,
# the error may still occur ocasionally.
ssl_sock.send(b'0000')
def _loopback_for_cert(certificate, private_key, certificate_chain):
"""Create a loopback connection to parse a cert with a private key."""
context = ssl.create_default_context(cafile=certificate_chain)
context.load_cert_chain(certificate, private_key)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
# Python 3+ Unix, Python 3.5+ Windows
client, server = socket.socketpair()
try:
# `wrap_socket` will block until the ssl handshake is complete.
# it must be called on both ends at the same time -> thread
# openssl will cache the peer's cert during a successful handshake
# and return it via `getpeercert` even after the socket is closed.
# when `close` is called, the SSL shutdown notice will be sent
# and then python will wait to receive the corollary shutdown.
thread = threading.Thread(
target=_loopback_for_cert_thread, args=(context, server),
)
try:
thread.start()
with context.wrap_socket(
client, do_handshake_on_connect=True,
server_side=False,
) as ssl_sock:
ssl_sock.recv(4)
return ssl_sock.getpeercert()
finally:
thread.join()
finally:
client.close()
server.close()
def _parse_cert(certificate, private_key, certificate_chain):
"""Parse a certificate."""
# loopback_for_cert uses socket.socketpair which was only
# introduced in Python 3.0 for *nix and 3.5 for Windows
# and requires OS support (AttributeError, OSError)
# it also requires a private key either in its own file
# or combined with the cert (SSLError)
with suppress(AttributeError, ssl.SSLError, OSError):
return _loopback_for_cert(certificate, private_key, certificate_chain)
# KLUDGE: using an undocumented, private, test method to parse a cert
# unfortunately, it is the only built-in way without a connection
# as a private, undocumented method, it may change at any time
# so be tolerant of *any* possible errors it may raise
with suppress(Exception):
return ssl._ssl._test_decode_cert(certificate)
return {}
def _sni_callback(sock, sni, context):
"""Handle the SNI callback to tag the socket with the SNI."""
sock.sni = sni
# return None to allow the TLS negotiation to continue
class BuiltinSSLAdapter(Adapter):
"""A wrapper for integrating Python's builtin ssl module with Cheroot."""
"""Wrapper for integrating Python's builtin :py:mod:`ssl` with Cheroot."""
certificate = None
"""The filename of the server SSL certificate."""
"""The file name of the server SSL certificate."""
private_key = None
"""The filename of the server's private key file."""
"""The file name of the server's private key file."""
certificate_chain = None
"""The filename of the certificate chain file."""
context = None
"""The ssl.SSLContext that will be used to wrap sockets."""
"""The file name of the certificate chain file."""
ciphers = None
"""The ciphers list of SSL."""
# from mod_ssl/pkg.sslmod/ssl_engine_vars.c ssl_var_lookup_ssl_cert
CERT_KEY_TO_ENV = {
'subject': 'SSL_CLIENT_S_DN',
'issuer': 'SSL_CLIENT_I_DN',
'version': 'M_VERSION',
'serialNumber': 'M_SERIAL',
'notBefore': 'V_START',
'notAfter': 'V_END',
'subject': 'S_DN',
'issuer': 'I_DN',
'subjectAltName': 'SAN',
# not parsed by the Python standard library
# - A_SIG
# - A_KEY
# not provided by mod_ssl
# - OCSP
# - caIssuers
# - crlDistributionPoints
}
# from mod_ssl/pkg.sslmod/ssl_engine_vars.c ssl_var_lookup_ssl_cert_dn_rec
CERT_KEY_TO_LDAP_CODE = {
'countryName': 'C',
'stateOrProvinceName': 'ST',
# NOTE: mod_ssl also provides 'stateOrProvinceName' as 'SP'
# for compatibility with SSLeay
'localityName': 'L',
'organizationName': 'O',
'organizationalUnitName': 'OU',
'commonName': 'CN',
'title': 'T',
'initials': 'I',
'givenName': 'G',
'surname': 'S',
'description': 'D',
'userid': 'UID',
'emailAddress': 'Email',
# not provided by mod_ssl
# - dnQualifier: DNQ
# - domainComponent: DC
# - postalCode: PC
# - streetAddress: STREET
# - serialNumber
# - generationQualifier
# - pseudonym
# - jurisdictionCountryName
# - jurisdictionLocalityName
# - jurisdictionStateOrProvince
# - businessCategory
}
def __init__(
@ -102,6 +227,45 @@ class BuiltinSSLAdapter(Adapter):
if self.ciphers is not None:
self.context.set_ciphers(ciphers)
self._server_env = self._make_env_cert_dict(
'SSL_SERVER',
_parse_cert(certificate, private_key, self.certificate_chain),
)
if not self._server_env:
return
cert = None
with open(certificate, mode='rt') as f:
cert = f.read()
# strip off any keys by only taking the first certificate
cert_start = cert.find(ssl.PEM_HEADER)
if cert_start == -1:
return
cert_end = cert.find(ssl.PEM_FOOTER, cert_start)
if cert_end == -1:
return
cert_end += len(ssl.PEM_FOOTER)
self._server_env['SSL_SERVER_CERT'] = cert[cert_start:cert_end]
@property
def context(self):
""":py:class:`~ssl.SSLContext` that will be used to wrap sockets."""
return self._context
@context.setter
def context(self, context):
"""Set the ssl ``context`` to use."""
self._context = context
# Python 3.7+
# if a context is provided via `cherrypy.config.update` then
# `self.context` will be set after `__init__`
# use a property to intercept it to add an SNI callback
# but don't override the user's callback
# TODO: chain callbacks
with suppress(AttributeError):
if ssl.HAS_SNI and context.sni_callback is None:
context.sni_callback = _sni_callback
def bind(self, sock):
"""Wrap and return the given socket."""
return super(BuiltinSSLAdapter, self).bind(sock)
@ -135,6 +299,8 @@ class BuiltinSSLAdapter(Adapter):
'no shared cipher', 'certificate unknown',
'ccs received early',
'certificate verify failed', # client cert w/o trusted CA
'version too low', # caused by SSL3 connections
'unsupported protocol', # caused by TLS1 connections
)
if _assert_ssl_exc_contains(ex, *_block_errors):
# Accepted error, let's pass
@ -148,7 +314,8 @@ class BuiltinSSLAdapter(Adapter):
except generic_socket_error as exc:
"""It is unclear why exactly this happens.
It's reproducible only with openssl>1.0 and stdlib ``ssl`` wrapper.
It's reproducible only with openssl>1.0 and stdlib
:py:mod:`ssl` wrapper.
In CherryPy it's triggered by Checker plugin, which connects
to the app listening to the socket port in TLS mode via plain
HTTP during startup (from the same process).
@ -163,7 +330,6 @@ class BuiltinSSLAdapter(Adapter):
raise
return s, self.get_environ(s)
# TODO: fill this out more with mod ssl env
def get_environ(self, sock):
"""Create WSGI environ entries to be merged into each request."""
cipher = sock.cipher()
@ -172,22 +338,117 @@ class BuiltinSSLAdapter(Adapter):
'HTTPS': 'on',
'SSL_PROTOCOL': cipher[1],
'SSL_CIPHER': cipher[0],
# SSL_VERSION_INTERFACE string The mod_ssl program version
# SSL_VERSION_LIBRARY string The OpenSSL program version
'SSL_CIPHER_EXPORT': '',
'SSL_CIPHER_USEKEYSIZE': cipher[2],
'SSL_VERSION_INTERFACE': '%s Python/%s' % (
HTTPServer.version, sys.version,
),
'SSL_VERSION_LIBRARY': ssl.OPENSSL_VERSION,
'SSL_CLIENT_VERIFY': 'NONE',
# 'NONE' - client did not provide a cert (overriden below)
}
# Python 3.3+
with suppress(AttributeError):
compression = sock.compression()
if compression is not None:
ssl_environ['SSL_COMPRESS_METHOD'] = compression
# Python 3.6+
with suppress(AttributeError):
ssl_environ['SSL_SESSION_ID'] = sock.session.id.hex()
with suppress(AttributeError):
target_cipher = cipher[:2]
for cip in sock.context.get_ciphers():
if target_cipher == (cip['name'], cip['protocol']):
ssl_environ['SSL_CIPHER_ALGKEYSIZE'] = cip['alg_bits']
break
# Python 3.7+ sni_callback
with suppress(AttributeError):
ssl_environ['SSL_TLS_SNI'] = sock.sni
if self.context and self.context.verify_mode != ssl.CERT_NONE:
client_cert = sock.getpeercert()
if client_cert:
for cert_key, env_var in self.CERT_KEY_TO_ENV.items():
ssl_environ.update(
self.env_dn_dict(env_var, client_cert.get(cert_key)),
)
# builtin ssl **ALWAYS** validates client certificates
# and terminates the connection on failure
ssl_environ['SSL_CLIENT_VERIFY'] = 'SUCCESS'
ssl_environ.update(
self._make_env_cert_dict('SSL_CLIENT', client_cert),
)
ssl_environ['SSL_CLIENT_CERT'] = ssl.DER_cert_to_PEM_cert(
sock.getpeercert(binary_form=True),
).strip()
ssl_environ.update(self._server_env)
# not supplied by the Python standard library (as of 3.8)
# - SSL_SESSION_RESUMED
# - SSL_SECURE_RENEG
# - SSL_CLIENT_CERT_CHAIN_n
# - SRP_USER
# - SRP_USERINFO
return ssl_environ
def env_dn_dict(self, env_prefix, cert_value):
"""Return a dict of WSGI environment variables for a client cert DN.
def _make_env_cert_dict(self, env_prefix, parsed_cert):
"""Return a dict of WSGI environment variables for a certificate.
E.g. SSL_CLIENT_M_VERSION, SSL_CLIENT_M_SERIAL, etc.
See https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#envvars.
"""
if not parsed_cert:
return {}
env = {}
for cert_key, env_var in self.CERT_KEY_TO_ENV.items():
key = '%s_%s' % (env_prefix, env_var)
value = parsed_cert.get(cert_key)
if env_var == 'SAN':
env.update(self._make_env_san_dict(key, value))
elif env_var.endswith('_DN'):
env.update(self._make_env_dn_dict(key, value))
else:
env[key] = str(value)
# mod_ssl 2.1+; Python 3.2+
# number of days until the certificate expires
if 'notBefore' in parsed_cert:
remain = ssl.cert_time_to_seconds(parsed_cert['notAfter'])
remain -= ssl.cert_time_to_seconds(parsed_cert['notBefore'])
remain /= 60 * 60 * 24
env['%s_V_REMAIN' % (env_prefix,)] = str(int(remain))
return env
def _make_env_san_dict(self, env_prefix, cert_value):
"""Return a dict of WSGI environment variables for a certificate DN.
E.g. SSL_CLIENT_SAN_Email_0, SSL_CLIENT_SAN_DNS_0, etc.
See SSL_CLIENT_SAN_* at
https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#envvars.
"""
if not cert_value:
return {}
env = {}
dns_count = 0
email_count = 0
for attr_name, val in cert_value:
if attr_name == 'DNS':
env['%s_DNS_%i' % (env_prefix, dns_count)] = val
dns_count += 1
elif attr_name == 'Email':
env['%s_Email_%i' % (env_prefix, email_count)] = val
email_count += 1
# other mod_ssl SAN vars:
# - SAN_OTHER_msUPN_n
return env
def _make_env_dn_dict(self, env_prefix, cert_value):
"""Return a dict of WSGI environment variables for a certificate DN.
E.g. SSL_CLIENT_S_DN_CN, SSL_CLIENT_S_DN_C, etc.
See SSL_CLIENT_S_DN_x509 at
@ -196,12 +457,26 @@ class BuiltinSSLAdapter(Adapter):
if not cert_value:
return {}
env = {}
dn = []
dn_attrs = {}
for rdn in cert_value:
for attr_name, val in rdn:
attr_code = self.CERT_KEY_TO_LDAP_CODE.get(attr_name)
if attr_code:
env['%s_%s' % (env_prefix, attr_code)] = val
dn.append('%s=%s' % (attr_code or attr_name, val))
if not attr_code:
continue
dn_attrs.setdefault(attr_code, [])
dn_attrs[attr_code].append(val)
env = {
env_prefix: ','.join(dn),
}
for attr_code, values in dn_attrs.items():
env['%s_%s' % (env_prefix, attr_code)] = ','.join(values)
if len(values) == 1:
continue
for i, val in enumerate(values):
env['%s_%s_%i' % (env_prefix, attr_code, i)] = val
return env
def makefile(self, sock, mode='r', bufsize=DEFAULT_BUFFER_SIZE):

View file

@ -1,46 +1,67 @@
"""
A library for integrating pyOpenSSL with Cheroot.
A library for integrating :doc:`pyOpenSSL <pyopenssl:index>` with Cheroot.
The OpenSSL module must be importable for SSL functionality.
You can obtain it from `here <https://launchpad.net/pyopenssl>`_.
The :py:mod:`OpenSSL <pyopenssl:OpenSSL>` module must be importable
for SSL/TLS/HTTPS functionality.
You can obtain it from `here <https://github.com/pyca/pyopenssl>`_.
To use this module, set HTTPServer.ssl_adapter to an instance of
ssl.Adapter. There are two ways to use SSL:
To use this module, set :py:attr:`HTTPServer.ssl_adapter
<cheroot.server.HTTPServer.ssl_adapter>` to an instance of
:py:class:`ssl.Adapter <cheroot.ssl.Adapter>`.
There are two ways to use :abbr:`TLS (Transport-Level Security)`:
Method One
----------
* ``ssl_adapter.context``: an instance of SSL.Context.
* :py:attr:`ssl_adapter.context
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.context>`: an instance of
:py:class:`SSL.Context <pyopenssl:OpenSSL.SSL.Context>`.
If this is not None, it is assumed to be an SSL.Context instance,
and will be passed to SSL.Connection on bind(). The developer is
responsible for forming a valid Context object. This approach is
to be preferred for more flexibility, e.g. if the cert and key are
streams instead of files, or need decryption, or SSL.SSLv3_METHOD
is desired instead of the default SSL.SSLv23_METHOD, etc. Consult
the pyOpenSSL documentation for complete options.
If this is not None, it is assumed to be an :py:class:`SSL.Context
<pyopenssl:OpenSSL.SSL.Context>` instance, and will be passed to
:py:class:`SSL.Connection <pyopenssl:OpenSSL.SSL.Connection>` on bind().
The developer is responsible for forming a valid :py:class:`Context
<pyopenssl:OpenSSL.SSL.Context>` object. This
approach is to be preferred for more flexibility, e.g. if the cert and
key are streams instead of files, or need decryption, or
:py:data:`SSL.SSLv3_METHOD <pyopenssl:OpenSSL.SSL.SSLv3_METHOD>`
is desired instead of the default :py:data:`SSL.SSLv23_METHOD
<pyopenssl:OpenSSL.SSL.SSLv3_METHOD>`, etc. Consult
the :doc:`pyOpenSSL <pyopenssl:api/ssl>` documentation for
complete options.
Method Two (shortcut)
---------------------
* ``ssl_adapter.certificate``: the filename of the server SSL certificate.
* ``ssl_adapter.private_key``: the filename of the server's private key file.
* :py:attr:`ssl_adapter.certificate
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.certificate>`: the file name
of the server's TLS certificate.
* :py:attr:`ssl_adapter.private_key
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.private_key>`: the file name
of the server's private key file.
Both are None by default. If ssl_adapter.context is None, but .private_key
and .certificate are both given and valid, they will be read, and the
context will be automatically created from them.
Both are :py:data:`None` by default. If :py:attr:`ssl_adapter.context
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.context>` is :py:data:`None`,
but ``.private_key`` and ``.certificate`` are both given and valid, they
will be read, and the context will be automatically created from them.
.. spelling::
pyopenssl
"""
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import socket
import sys
import threading
import time
import six
try:
import OpenSSL.version
from OpenSSL import SSL
from OpenSSL import crypto
@ -57,13 +78,14 @@ from ..makefile import StreamReader, StreamWriter
class SSLFileobjectMixin:
"""Base mixin for an SSL socket stream."""
"""Base mixin for a TLS socket stream."""
ssl_timeout = 3
ssl_retry = .01
def _safe_call(self, is_reader, call, *args, **kwargs):
"""Wrap the given call with SSL error-trapping.
# FIXME:
def _safe_call(self, is_reader, call, *args, **kwargs): # noqa: C901
"""Wrap the given call with TLS error-trapping.
is_reader: if False EOF errors will be raised. If True, EOF errors
will return "" (to emulate normal sockets).
@ -209,9 +231,11 @@ class SSLConnectionProxyMeta:
@six.add_metaclass(SSLConnectionProxyMeta)
class SSLConnection:
"""A thread-safe wrapper for an SSL.Connection.
r"""A thread-safe wrapper for an ``SSL.Connection``.
``*args``: the arguments to create the wrapped ``SSL.Connection(*args)``.
:param tuple args: the arguments to create the wrapped \
:py:class:`SSL.Connection(*args) \
<pyopenssl:OpenSSL.SSL.Connection>`
"""
def __init__(self, *args):
@ -224,22 +248,24 @@ class pyOpenSSLAdapter(Adapter):
"""A wrapper for integrating pyOpenSSL with Cheroot."""
certificate = None
"""The filename of the server SSL certificate."""
"""The file name of the server's TLS certificate."""
private_key = None
"""The filename of the server's private key file."""
"""The file name of the server's private key file."""
certificate_chain = None
"""Optional. The filename of CA's intermediate certificate bundle.
"""Optional. The file name of CA's intermediate certificate bundle.
This is needed for cheaper "chained root" SSL certificates, and should be
left as None if not required."""
This is needed for cheaper "chained root" TLS certificates,
and should be left as :py:data:`None` if not required."""
context = None
"""An instance of SSL.Context."""
"""
An instance of :py:class:`SSL.Context <pyopenssl:OpenSSL.SSL.Context>`.
"""
ciphers = None
"""The ciphers list of SSL."""
"""The ciphers list of TLS."""
def __init__(
self, certificate, private_key, certificate_chain=None,
@ -265,10 +291,16 @@ class pyOpenSSLAdapter(Adapter):
def wrap(self, sock):
"""Wrap and return the given socket, plus WSGI environ entries."""
# pyOpenSSL doesn't perform the handshake until the first read/write
# forcing the handshake to complete tends to result in the connection
# closing so we can't reliably access protocol/client cert for the env
return sock, self._environ.copy()
def get_context(self):
"""Return an SSL.Context from self attributes."""
"""Return an ``SSL.Context`` from self attributes.
Ref: :py:class:`SSL.Context <pyopenssl:OpenSSL.SSL.Context>`
"""
# See https://code.activestate.com/recipes/442473/
c = SSL.Context(SSL.SSLv23_METHOD)
c.use_privatekey_file(self.private_key)
@ -280,18 +312,25 @@ class pyOpenSSLAdapter(Adapter):
def get_environ(self):
"""Return WSGI environ entries to be merged into each request."""
ssl_environ = {
'wsgi.url_scheme': 'https',
'HTTPS': 'on',
# pyOpenSSL doesn't provide access to any of these AFAICT
# 'SSL_PROTOCOL': 'SSLv2',
# SSL_CIPHER string The cipher specification name
# SSL_VERSION_INTERFACE string The mod_ssl program version
# SSL_VERSION_LIBRARY string The OpenSSL program version
'SSL_VERSION_INTERFACE': '%s %s/%s Python/%s' % (
cheroot_server.HTTPServer.version,
OpenSSL.version.__title__, OpenSSL.version.__version__,
sys.version,
),
'SSL_VERSION_LIBRARY': SSL.SSLeay_version(
SSL.SSLEAY_VERSION,
).decode(),
}
if self.certificate:
# Server certificate attributes
cert = open(self.certificate, 'rb').read()
cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
with open(self.certificate, 'rb') as cert_file:
cert = crypto.load_certificate(
crypto.FILETYPE_PEM, cert_file.read(),
)
ssl_environ.update({
'SSL_SERVER_M_VERSION': cert.get_version(),
'SSL_SERVER_M_SERIAL': cert.get_serial_number(),