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