diff --git a/changelog.d/1630.change.rst b/changelog.d/1630.change.rst new file mode 100644 index 0000000000..3af89d3a65 --- /dev/null +++ b/changelog.d/1630.change.rst @@ -0,0 +1 @@ +Support SSL_CERT_FILE, REQUESTS_CA_BUNDLE and CURL_CA_BUNDLE for a custom local CA file path. diff --git a/docs/easy_install.txt b/docs/easy_install.txt index aa11f89083..e788d67af3 100644 --- a/docs/easy_install.txt +++ b/docs/easy_install.txt @@ -390,6 +390,17 @@ The above example would then allow downloads only from hosts in the ``python.org`` and ``myintranet.example.com`` domains, unless overridden on the command line. +Custom TLS (SSL) certificate validation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +EasyInstall supports the environment variables ``SSL_CERT_FILE``, +``REQUESTS_CA_BUNDLE`` and ``CURL_CA_BUNDLE`` (as established by the +`openssl `_, +`requests `_ and +`curl `_ projects) to specify a custom local CA cert +path. The path must refer to a CA cert file, not a directory. If multiple of +these env vars are set the 1st one found in above precedence order gets used +and is expected to contain a valid file path. Installing on Un-networked Machines ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/setuptools/ssl_support.py b/setuptools/ssl_support.py index 226db694bb..fd895e289c 100644 --- a/setuptools/ssl_support.py +++ b/setuptools/ssl_support.py @@ -243,11 +243,36 @@ def close(self): return _wincerts.name +def get_env_ca_bundle(): + """Return a CA bundle file path set through environment variables. + + Supports the REQUESTS_CA_BUNDLE and CURL_CA_BUNDLE env vars for setting + the path to a CA bundle (Current restriction: path must be a file path, + not a directory). + """ + # Be compatible with requests and curl ca bundle environment configuration + # (somewhat, only bundle files but not directories) + ca_bundle = (os.environ.get('SSL_CERT_FILE') or + os.environ.get('REQUESTS_CA_BUNDLE') or + os.environ.get('CURL_CA_BUNDLE')) + if ca_bundle is not None: + if os.path.isdir(ca_bundle): + raise NotImplementedError( + "TLS CA certificate bundle directory paths are currently " + "not supported.") + if not os.path.isfile(ca_bundle): + raise IOError( + "Could not find a suitable TLS CA certificate bundle file, " + "invalid path: {}".format(ca_bundle)) + return ca_bundle + + def find_ca_bundle(): """Return an existing CA bundle path, or None""" extant_cert_paths = filter(os.path.isfile, cert_paths) return ( - get_win_certfile() + get_env_ca_bundle() + or get_win_certfile() or next(extant_cert_paths, None) or _certifi_where() ) diff --git a/setuptools/tests/test_ssl_support.py b/setuptools/tests/test_ssl_support.py new file mode 100644 index 0000000000..22fbd039da --- /dev/null +++ b/setuptools/tests/test_ssl_support.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +"""TLS/SSL support tests. +""" + + +from __future__ import unicode_literals + +import pytest +from setuptools import ssl_support + + +class TestGetEnvCABundle: + @pytest.mark.parametrize( + 'env_var', ['SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE']) + def test_get_env_ca_bundle(self, env_var, monkeypatch, tmp_path): + # Test that get_env_ca_bundle() respects the env variables. + ca_bundle_path = tmp_path / 'ca-bundle.crt' + ca_bundle_path_str = str(ca_bundle_path) + # Create a valid file, content is irrelevant + ca_bundle_path.write_text('') + monkeypatch.setenv(env_var, ca_bundle_path_str) + assert ssl_support.get_env_ca_bundle() == ca_bundle_path_str + + @pytest.mark.parametrize( + 'env_var', ['SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE']) + def test_get_env_ca_bundle_invalid_file( + self, env_var, monkeypatch, tmp_path): + # Test get_env_ca_bundle() behaviour if the given path is not an + # existing file. + ca_bundle_path = tmp_path / 'non-existant' + ca_bundle_path_str = str(ca_bundle_path) + # Don't create a file! + monkeypatch.setenv(env_var, ca_bundle_path_str) + with pytest.raises(IOError): + _ = ssl_support.get_env_ca_bundle() + + @pytest.mark.parametrize( + 'env_var', ['SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE']) + def test_get_env_ca_bundle_dir(self, env_var, monkeypatch, tmp_path): + # Test get_env_ca_bundle() behaviour if the given ca bundle path is a + # directory (currently unsupported). + ca_bundle_path = tmp_path / 'ca-bundles' + ca_bundle_path_str = str(ca_bundle_path) + ca_bundle_path.mkdir() + monkeypatch.setenv(env_var, ca_bundle_path_str) + with pytest.raises(NotImplementedError): + _ = ssl_support.get_env_ca_bundle() + + @pytest.mark.parametrize( + 'env_variables', [ + ('SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE'), + ('REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE')]) + def test_get_env_ca_bundle_envvar_precedence( + self, env_variables, monkeypatch, tmp_path): + # Test precedence when multiple env vars are set. + for env_var in env_variables: + ca_bundle_path = tmp_path / (env_var + '.crt') + ca_bundle_path_str = str(ca_bundle_path) + # Create a valid file, content is irrelevant + ca_bundle_path.write_text('') + monkeypatch.setenv(env_var, ca_bundle_path_str) + expected = str(tmp_path / (env_variables[0] + '.crt')) + assert ssl_support.get_env_ca_bundle() == expected + + +class TestFindCABundle: + @pytest.mark.parametrize( + 'env_var', ['SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE']) + def test_find_ca_bundle(self, env_var, monkeypatch, tmp_path): + ca_bundle_path = tmp_path / 'ca-bundle.crt' + ca_bundle_path_str = str(ca_bundle_path) + # Create a valid file, content is irrelevant + ca_bundle_path.write_text('') + monkeypatch.setenv(env_var, ca_bundle_path_str) + assert ssl_support.find_ca_bundle() == ca_bundle_path_str