"""Tests for TLS support.""" # -*- coding: utf-8 -*- # vim: set fileencoding=utf-8 : from __future__ import absolute_import, division, print_function __metaclass__ = type import functools import json import os import ssl import subprocess import sys import threading import time import traceback import OpenSSL.SSL import pytest import requests import six import trustme from .._compat import bton, ntob, ntou from .._compat import IS_ABOVE_OPENSSL10, IS_CI, IS_PYPY from .._compat import IS_LINUX, IS_MACOS, IS_WINDOWS from ..server import HTTPServer, get_ssl_adapter_class from ..testing import ( ANY_INTERFACE_IPV4, ANY_INTERFACE_IPV6, EPHEMERAL_PORT, # get_server_client, _get_conn_data, _probe_ipv6_sock, ) from ..wsgi import Gateway_10 IS_GITHUB_ACTIONS_WORKFLOW = bool(os.getenv('GITHUB_WORKFLOW')) IS_WIN2016 = ( IS_WINDOWS # pylint: disable=unsupported-membership-test and b'Microsoft Windows Server 2016 Datacenter' in subprocess.check_output( ('systeminfo',), ) ) IS_LIBRESSL_BACKEND = ssl.OPENSSL_VERSION.startswith('LibreSSL') IS_PYOPENSSL_SSL_VERSION_1_0 = ( OpenSSL.SSL.SSLeay_version(OpenSSL.SSL.SSLEAY_VERSION). startswith(b'OpenSSL 1.0.') ) PY27 = sys.version_info[:2] == (2, 7) PY34 = sys.version_info[:2] == (3, 4) PY3 = not six.PY2 PY310_PLUS = sys.version_info[:2] >= (3, 10) _stdlib_to_openssl_verify = { ssl.CERT_NONE: OpenSSL.SSL.VERIFY_NONE, ssl.CERT_OPTIONAL: OpenSSL.SSL.VERIFY_PEER, ssl.CERT_REQUIRED: OpenSSL.SSL.VERIFY_PEER + OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT, } fails_under_py3 = pytest.mark.xfail( not six.PY2, reason='Fails under Python 3+', ) fails_under_py3_in_pypy = pytest.mark.xfail( not six.PY2 and IS_PYPY, reason='Fails under PyPy3', ) missing_ipv6 = pytest.mark.skipif( not _probe_ipv6_sock('::1'), reason='' 'IPv6 is disabled ' '(for example, under Travis CI ' 'which runs under GCE supporting only IPv4)', ) class HelloWorldGateway(Gateway_10): """Gateway responding with Hello World to root URI.""" def respond(self): """Respond with dummy content via HTTP.""" req = self.req req_uri = bton(req.uri) if req_uri == '/': req.status = b'200 OK' req.ensure_headers_sent() req.write(b'Hello world!') return if req_uri == '/env': req.status = b'200 OK' req.ensure_headers_sent() env = self.get_environ() # drop files so that it can be json dumped env.pop('wsgi.errors') env.pop('wsgi.input') print(env) req.write(json.dumps(env).encode('utf-8')) return return super(HelloWorldGateway, self).respond() def make_tls_http_server(bind_addr, ssl_adapter, request): """Create and start an HTTP server bound to ``bind_addr``.""" httpserver = HTTPServer( bind_addr=bind_addr, gateway=HelloWorldGateway, ) # httpserver.gateway = HelloWorldGateway httpserver.ssl_adapter = ssl_adapter threading.Thread(target=httpserver.safe_start).start() while not httpserver.ready: time.sleep(0.1) request.addfinalizer(httpserver.stop) return httpserver @pytest.fixture def tls_http_server(request): """Provision a server creator as a fixture.""" return functools.partial(make_tls_http_server, request=request) @pytest.fixture def ca(): """Provide a certificate authority via fixture.""" return trustme.CA() @pytest.fixture def tls_ca_certificate_pem_path(ca): """Provide a certificate authority certificate file via fixture.""" with ca.cert_pem.tempfile() as ca_cert_pem: yield ca_cert_pem @pytest.fixture def tls_certificate(ca): """Provide a leaf certificate via fixture.""" interface, _host, _port = _get_conn_data(ANY_INTERFACE_IPV4) return ca.issue_cert(ntou(interface)) @pytest.fixture def tls_certificate_chain_pem_path(tls_certificate): """Provide a certificate chain PEM file path via fixture.""" with tls_certificate.private_key_and_cert_chain_pem.tempfile() as cert_pem: yield cert_pem @pytest.fixture def tls_certificate_private_key_pem_path(tls_certificate): """Provide a certificate private key PEM file path via fixture.""" with tls_certificate.private_key_pem.tempfile() as cert_key_pem: yield cert_key_pem def _thread_except_hook(exceptions, args): """Append uncaught exception ``args`` in threads to ``exceptions``.""" if issubclass(args.exc_type, SystemExit): return # cannot store the exception, it references the thread's stack exceptions.append(( args.exc_type, str(args.exc_value), ''.join( traceback.format_exception( args.exc_type, args.exc_value, args.exc_traceback, ), ), )) @pytest.fixture def thread_exceptions(): """Provide a list of uncaught exceptions from threads via a fixture. Only catches exceptions on Python 3.8+. The list contains: ``(type, str(value), str(traceback))`` """ exceptions = [] # Python 3.8+ orig_hook = getattr(threading, 'excepthook', None) if orig_hook is not None: threading.excepthook = functools.partial( _thread_except_hook, exceptions, ) try: yield exceptions finally: if orig_hook is not None: threading.excepthook = orig_hook @pytest.mark.parametrize( 'adapter_type', ( 'builtin', 'pyopenssl', ), ) def test_ssl_adapters( tls_http_server, adapter_type, tls_certificate, tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path, tls_ca_certificate_pem_path, ): """Test ability to connect to server via HTTPS using adapters.""" interface, _host, port = _get_conn_data(ANY_INTERFACE_IPV4) tls_adapter_cls = get_ssl_adapter_class(name=adapter_type) tls_adapter = tls_adapter_cls( tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path, ) if adapter_type == 'pyopenssl': tls_adapter.context = tls_adapter.get_context() tls_certificate.configure_cert(tls_adapter.context) tlshttpserver = tls_http_server((interface, port), tls_adapter) # testclient = get_server_client(tlshttpserver) # testclient.get('/') interface, _host, port = _get_conn_data( tlshttpserver.bind_addr, ) resp = requests.get( 'https://{host!s}:{port!s}/'.format(host=interface, port=port), verify=tls_ca_certificate_pem_path, ) assert resp.status_code == 200 assert resp.text == 'Hello world!' @pytest.mark.parametrize( # noqa: C901 # FIXME 'adapter_type', ( 'builtin', 'pyopenssl', ), ) @pytest.mark.parametrize( ('is_trusted_cert', 'tls_client_identity'), ( (True, 'localhost'), (True, '127.0.0.1'), (True, '*.localhost'), (True, 'not_localhost'), (False, 'localhost'), ), ) @pytest.mark.parametrize( 'tls_verify_mode', ( ssl.CERT_NONE, # server shouldn't validate client cert ssl.CERT_OPTIONAL, # same as CERT_REQUIRED in client mode, don't use ssl.CERT_REQUIRED, # server should validate if client cert CA is OK ), ) @pytest.mark.xfail( IS_PYPY and IS_CI, reason='Fails under PyPy in CI for unknown reason', strict=False, ) def test_tls_client_auth( # noqa: C901 # FIXME # FIXME: remove twisted logic, separate tests mocker, tls_http_server, adapter_type, ca, tls_certificate, tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path, tls_ca_certificate_pem_path, is_trusted_cert, tls_client_identity, tls_verify_mode, ): """Verify that client TLS certificate auth works correctly.""" test_cert_rejection = ( tls_verify_mode != ssl.CERT_NONE and not is_trusted_cert ) interface, _host, port = _get_conn_data(ANY_INTERFACE_IPV4) client_cert_root_ca = ca if is_trusted_cert else trustme.CA() with mocker.mock_module.patch( 'idna.core.ulabel', return_value=ntob(tls_client_identity), ): client_cert = client_cert_root_ca.issue_cert( ntou(tls_client_identity), ) del client_cert_root_ca with client_cert.private_key_and_cert_chain_pem.tempfile() as cl_pem: tls_adapter_cls = get_ssl_adapter_class(name=adapter_type) tls_adapter = tls_adapter_cls( tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path, ) if adapter_type == 'pyopenssl': tls_adapter.context = tls_adapter.get_context() tls_adapter.context.set_verify( _stdlib_to_openssl_verify[tls_verify_mode], lambda conn, cert, errno, depth, preverify_ok: preverify_ok, ) else: tls_adapter.context.verify_mode = tls_verify_mode ca.configure_trust(tls_adapter.context) tls_certificate.configure_cert(tls_adapter.context) tlshttpserver = tls_http_server((interface, port), tls_adapter) interface, _host, port = _get_conn_data(tlshttpserver.bind_addr) make_https_request = functools.partial( requests.get, 'https://{host!s}:{port!s}/'.format(host=interface, port=port), # Server TLS certificate verification: verify=tls_ca_certificate_pem_path, # Client TLS certificate verification: cert=cl_pem, ) if not test_cert_rejection: resp = make_https_request() is_req_successful = resp.status_code == 200 if ( not is_req_successful and IS_PYOPENSSL_SSL_VERSION_1_0 and adapter_type == 'builtin' and tls_verify_mode == ssl.CERT_REQUIRED and tls_client_identity == 'localhost' and is_trusted_cert ) or PY34: pytest.xfail( 'OpenSSL 1.0 has problems with verifying client certs', ) assert is_req_successful assert resp.text == 'Hello world!' return # xfail some flaky tests # https://github.com/cherrypy/cheroot/issues/237 issue_237 = ( IS_MACOS and adapter_type == 'builtin' and tls_verify_mode != ssl.CERT_NONE ) if issue_237: pytest.xfail('Test sometimes fails') expected_ssl_errors = ( requests.exceptions.SSLError, OpenSSL.SSL.Error, ) if PY34 else ( requests.exceptions.SSLError, ) if IS_WINDOWS or IS_GITHUB_ACTIONS_WORKFLOW: expected_ssl_errors += requests.exceptions.ConnectionError, with pytest.raises(expected_ssl_errors) as ssl_err: make_https_request() if PY34 and isinstance(ssl_err, OpenSSL.SSL.Error): pytest.xfail( 'OpenSSL behaves wierdly under Python 3.4 ' 'because of an outdated urllib3', ) try: err_text = ssl_err.value.args[0].reason.args[0].args[0] except AttributeError: if PY34: pytest.xfail('OpenSSL behaves wierdly under Python 3.4') elif IS_WINDOWS or IS_GITHUB_ACTIONS_WORKFLOW: err_text = str(ssl_err.value) else: raise if isinstance(err_text, int): err_text = str(ssl_err.value) expected_substrings = ( 'sslv3 alert bad certificate' if IS_LIBRESSL_BACKEND else 'tlsv1 alert unknown ca', ) if not six.PY2: if IS_MACOS and IS_PYPY and adapter_type == 'pyopenssl': expected_substrings = ('tlsv1 alert unknown ca',) if ( tls_verify_mode in ( ssl.CERT_REQUIRED, ssl.CERT_OPTIONAL, ) and not is_trusted_cert and tls_client_identity == 'localhost' ): expected_substrings += ( 'bad handshake: ' "SysCallError(10054, 'WSAECONNRESET')", "('Connection aborted.', " 'OSError("(10054, \'WSAECONNRESET\')"))', "('Connection aborted.', " 'OSError("(10054, \'WSAECONNRESET\')",))', "('Connection aborted.', " 'error("(10054, \'WSAECONNRESET\')",))', "('Connection aborted.', " 'ConnectionResetError(10054, ' "'An existing connection was forcibly closed " "by the remote host', None, 10054, None))", "('Connection aborted.', " 'error(10054, ' "'An existing connection was forcibly closed " "by the remote host'))", ) if IS_WINDOWS else ( "('Connection aborted.', " 'OSError("(104, \'ECONNRESET\')"))', "('Connection aborted.', " 'OSError("(104, \'ECONNRESET\')",))', "('Connection aborted.', " 'error("(104, \'ECONNRESET\')",))', "('Connection aborted.', " "ConnectionResetError(104, 'Connection reset by peer'))", "('Connection aborted.', " "error(104, 'Connection reset by peer'))", ) if ( IS_GITHUB_ACTIONS_WORKFLOW and IS_LINUX ) else ( "('Connection aborted.', " "BrokenPipeError(32, 'Broken pipe'))", ) if PY310_PLUS: # FIXME: Figure out what's happening and correct the problem expected_substrings += ( 'SSLError(SSLEOFError(8, ' "'EOF occurred in violation of protocol (_ssl.c:", ) if IS_GITHUB_ACTIONS_WORKFLOW and IS_WINDOWS and PY310_PLUS: expected_substrings += ( "('Connection aborted.', " 'RemoteDisconnected(' "'Remote end closed connection without response'))", ) assert any(e in err_text for e in expected_substrings) @pytest.mark.parametrize( # noqa: C901 # FIXME 'adapter_type', ( pytest.param( 'builtin', marks=pytest.mark.xfail( IS_GITHUB_ACTIONS_WORKFLOW and IS_MACOS and PY310_PLUS, reason='Unclosed TLS resource warnings happen on macOS ' 'under Python 3.10', strict=False, ), ), 'pyopenssl', ), ) @pytest.mark.parametrize( ('tls_verify_mode', 'use_client_cert'), ( (ssl.CERT_NONE, False), (ssl.CERT_NONE, True), (ssl.CERT_OPTIONAL, False), (ssl.CERT_OPTIONAL, True), (ssl.CERT_REQUIRED, True), ), ) def test_ssl_env( # noqa: C901 # FIXME thread_exceptions, recwarn, mocker, tls_http_server, adapter_type, ca, tls_verify_mode, tls_certificate, tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path, tls_ca_certificate_pem_path, use_client_cert, ): """Test the SSL environment generated by the SSL adapters.""" interface, _host, port = _get_conn_data(ANY_INTERFACE_IPV4) with mocker.mock_module.patch( 'idna.core.ulabel', return_value=ntob('127.0.0.1'), ): client_cert = ca.issue_cert(ntou('127.0.0.1')) with client_cert.private_key_and_cert_chain_pem.tempfile() as cl_pem: tls_adapter_cls = get_ssl_adapter_class(name=adapter_type) tls_adapter = tls_adapter_cls( tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path, ) if adapter_type == 'pyopenssl': tls_adapter.context = tls_adapter.get_context() tls_adapter.context.set_verify( _stdlib_to_openssl_verify[tls_verify_mode], lambda conn, cert, errno, depth, preverify_ok: preverify_ok, ) else: tls_adapter.context.verify_mode = tls_verify_mode ca.configure_trust(tls_adapter.context) tls_certificate.configure_cert(tls_adapter.context) tlswsgiserver = tls_http_server((interface, port), tls_adapter) interface, _host, port = _get_conn_data(tlswsgiserver.bind_addr) resp = requests.get( 'https://' + interface + ':' + str(port) + '/env', verify=tls_ca_certificate_pem_path, cert=cl_pem if use_client_cert else None, ) if PY34 and resp.status_code != 200: pytest.xfail( 'Python 3.4 has problems with verifying client certs', ) env = json.loads(resp.content.decode('utf-8')) # hard coded env assert env['wsgi.url_scheme'] == 'https' assert env['HTTPS'] == 'on' # ensure these are present for key in {'SSL_VERSION_INTERFACE', 'SSL_VERSION_LIBRARY'}: assert key in env # pyOpenSSL generates the env before the handshake completes if adapter_type == 'pyopenssl': return for key in {'SSL_PROTOCOL', 'SSL_CIPHER'}: assert key in env # client certificate env if tls_verify_mode == ssl.CERT_NONE or not use_client_cert: assert env['SSL_CLIENT_VERIFY'] == 'NONE' else: assert env['SSL_CLIENT_VERIFY'] == 'SUCCESS' with open(cl_pem, 'rt') as f: assert env['SSL_CLIENT_CERT'] in f.read() for key in { 'SSL_CLIENT_M_VERSION', 'SSL_CLIENT_M_SERIAL', 'SSL_CLIENT_I_DN', 'SSL_CLIENT_S_DN', }: assert key in env # builtin ssl environment generation may use a loopback socket # ensure no ResourceWarning was raised during the test # NOTE: python 2.7 does not emit ResourceWarning for ssl sockets if IS_PYPY: # NOTE: PyPy doesn't have ResourceWarning # Ref: https://doc.pypy.org/en/latest/cpython_differences.html return for warn in recwarn: if not issubclass(warn.category, ResourceWarning): continue # the tests can sporadically generate resource warnings # due to timing issues # all of these sporadic warnings appear to be about socket.socket # and have been observed to come from requests connection pool msg = str(warn.message) if 'socket.socket' in msg: pytest.xfail( '\n'.join(( 'Sometimes this test fails due to ' 'a socket.socket ResourceWarning:', msg, )), ) pytest.fail(msg) # to perform the ssl handshake over that loopback socket, # the builtin ssl environment generation uses a thread for _, _, trace in thread_exceptions: print(trace, file=sys.stderr) assert not thread_exceptions, ': '.join(( thread_exceptions[0][0].__name__, thread_exceptions[0][1], )) @pytest.mark.parametrize( 'ip_addr', ( ANY_INTERFACE_IPV4, ANY_INTERFACE_IPV6, ), ) def test_https_over_http_error(http_server, ip_addr): """Ensure that connecting over HTTPS to HTTP port is handled.""" httpserver = http_server.send((ip_addr, EPHEMERAL_PORT)) interface, _host, port = _get_conn_data(httpserver.bind_addr) with pytest.raises(ssl.SSLError) as ssl_err: six.moves.http_client.HTTPSConnection( '{interface}:{port}'.format( interface=interface, port=port, ), ).request('GET', '/') expected_substring = ( 'wrong version number' if IS_ABOVE_OPENSSL10 else 'unknown protocol' ) assert expected_substring in ssl_err.value.args[-1] http_over_https_error_builtin_marks = [] if IS_WINDOWS and six.PY2: http_over_https_error_builtin_marks.append( pytest.mark.flaky(reruns=5, reruns_delay=2), ) @pytest.mark.parametrize( 'adapter_type', ( pytest.param( 'builtin', marks=http_over_https_error_builtin_marks, ), 'pyopenssl', ), ) @pytest.mark.parametrize( 'ip_addr', ( ANY_INTERFACE_IPV4, pytest.param(ANY_INTERFACE_IPV6, marks=missing_ipv6), ), ) def test_http_over_https_error( tls_http_server, adapter_type, ca, ip_addr, tls_certificate, tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path, ): """Ensure that connecting over HTTP to HTTPS port is handled.""" # disable some flaky tests # https://github.com/cherrypy/cheroot/issues/225 issue_225 = ( IS_MACOS and adapter_type == 'builtin' ) if issue_225: pytest.xfail('Test fails in Travis-CI') tls_adapter_cls = get_ssl_adapter_class(name=adapter_type) tls_adapter = tls_adapter_cls( tls_certificate_chain_pem_path, tls_certificate_private_key_pem_path, ) if adapter_type == 'pyopenssl': tls_adapter.context = tls_adapter.get_context() tls_certificate.configure_cert(tls_adapter.context) interface, _host, port = _get_conn_data(ip_addr) tlshttpserver = tls_http_server((interface, port), tls_adapter) interface, _host, port = _get_conn_data( tlshttpserver.bind_addr, ) fqdn = interface if ip_addr is ANY_INTERFACE_IPV6: fqdn = '[{fqdn}]'.format(**locals()) expect_fallback_response_over_plain_http = ( ( adapter_type == 'pyopenssl' and (IS_ABOVE_OPENSSL10 or not six.PY2) ) or PY27 ) or ( IS_GITHUB_ACTIONS_WORKFLOW and IS_WINDOWS and six.PY2 and not IS_WIN2016 ) if ( IS_GITHUB_ACTIONS_WORKFLOW and IS_WINDOWS and six.PY2 and IS_WIN2016 and adapter_type == 'builtin' and ip_addr is ANY_INTERFACE_IPV6 ): expect_fallback_response_over_plain_http = True if ( IS_GITHUB_ACTIONS_WORKFLOW and IS_WINDOWS and six.PY2 and not IS_WIN2016 and adapter_type == 'builtin' and ip_addr is not ANY_INTERFACE_IPV6 ): expect_fallback_response_over_plain_http = False if expect_fallback_response_over_plain_http: resp = requests.get( 'http://{host!s}:{port!s}/'.format(host=fqdn, port=port), ) assert resp.status_code == 400 assert resp.text == ( 'The client sent a plain HTTP request, ' 'but this server only speaks HTTPS on this port.' ) return with pytest.raises(requests.exceptions.ConnectionError) as ssl_err: requests.get( # FIXME: make stdlib ssl behave like PyOpenSSL 'http://{host!s}:{port!s}/'.format(host=fqdn, port=port), ) if IS_LINUX: expected_error_code, expected_error_text = ( 104, 'Connection reset by peer', ) if IS_MACOS: expected_error_code, expected_error_text = ( 54, 'Connection reset by peer', ) if IS_WINDOWS: expected_error_code, expected_error_text = ( 10054, 'An existing connection was forcibly closed by the remote host', ) underlying_error = ssl_err.value.args[0].args[-1] err_text = str(underlying_error) assert underlying_error.errno == expected_error_code, ( 'The underlying error is {underlying_error!r}'. format(**locals()) ) assert expected_error_text in err_text