Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check OpenSSL environment variables before defaulting to certifi #196

Merged
merged 15 commits into from
Nov 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,21 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9]
python-version: ['3.7', '3.8', '3.9', '3.10']
os: [ubuntu-latest]
experimental: [false]
include:
- python-version: 3.10.0-beta.2
experimental: true
- python-version: '2.7'
os: ubuntu-20.04
experimental: false
- python-version: '3.5'
os: ubuntu-20.04
experimental: false
- python-version: '3.6'
os: ubuntu-20.04
experimental: false

runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
name: test-${{ matrix.python-version }}
continue-on-error: ${{ matrix.experimental }}
steps:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- Github workflow for changelog verification ([#218](https://github.com/opensearch-project/opensearch-py/pull/218))
### Changed
- Updated getting started to user guide ([#233](https://github.com/opensearch-project/opensearch-py/pull/233))
- Updated CA certificate handling to check OpenSSL environment variables before defaulting to certifi ([#196](https://github.com/opensearch-project/opensearch-py/pull/196))
### Deprecated

### Removed
Expand Down
9 changes: 8 additions & 1 deletion USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ from opensearchpy import OpenSearch
host = 'localhost'
port = 9200
auth = ('admin', 'admin') # For testing only. Don't store credentials in code.
ca_certs_path = '/full/path/to/root-ca.pem' # Provide a CA bundle if you use intermediate CAs with your root CA.

# Provide a CA bundle if you use intermediate CAs with your root CA.
# If this is not given, the CA bundle is is discovered from the first available
# following options:
# - OpenSSL environment variables SSL_CERT_FILE and SSL_CERT_DIR
# - certifi bundle (https://pypi.org/project/certifi/)
# - default behavior of the connection backend (most likely system certs)
ca_certs_path = '/full/path/to/root-ca.pem'

# Optional client certificates if you don't want to use HTTP basic authentication.
# client_cert_path = '/full/path/to/client.pem'
Expand Down
2 changes: 1 addition & 1 deletion opensearchpy/_async/client/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class AsyncOpenSearch(object):
self,
hosts: Any = ...,
transport_class: Type[AsyncTransport] = ...,
**kwargs: Any
**kwargs: Any,
) -> None: ...
def __repr__(self) -> str: ...
async def __aenter__(self) -> "AsyncOpenSearch": ...
Expand Down
24 changes: 7 additions & 17 deletions opensearchpy/_async/http_aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,6 @@
VERIFY_CERTS_DEFAULT = object()
SSL_SHOW_WARN_DEFAULT = object()

CA_CERTS = None

try:
import certifi

CA_CERTS = certifi.where()
except ImportError:
pass


class AsyncConnection(Connection):
"""Base class for Async HTTP connection implementations"""
Expand Down Expand Up @@ -185,28 +176,27 @@ def __init__(
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

ca_certs = CA_CERTS if ca_certs is None else ca_certs
ca_certs = self.default_ca_certs() if ca_certs is None else ca_certs
if verify_certs:
if not ca_certs:
raise ImproperlyConfigured(
"Root certificates are missing for certificate "
"validation. Either pass them in using the ca_certs parameter or "
"install certifi to use it automatically."
)
if os.path.isfile(ca_certs):
ssl_context.load_verify_locations(cafile=ca_certs)
elif os.path.isdir(ca_certs):
ssl_context.load_verify_locations(capath=ca_certs)
else:
raise ImproperlyConfigured("ca_certs parameter is not a path")
else:
if ssl_show_warn:
warnings.warn(
"Connecting to %s using SSL with verify_certs=False is insecure."
% self.host
)

if os.path.isfile(ca_certs):
ssl_context.load_verify_locations(cafile=ca_certs)
elif os.path.isdir(ca_certs):
ssl_context.load_verify_locations(capath=ca_certs)
else:
raise ImproperlyConfigured("ca_certs parameter is not a path")

# Use client_cert and client_key variables for SSL certificate configuration.
if client_cert and not os.path.isfile(client_cert):
raise ImproperlyConfigured("client_cert is not a path to a file")
Expand Down
19 changes: 19 additions & 0 deletions opensearchpy/connection/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,22 @@ def _raise_error(self, status_code, raw_data, content_type=None):

def _get_default_user_agent(self):
return "opensearch-py/%s (Python %s)" % (__versionstr__, python_version())

@staticmethod
def default_ca_certs():
"""
Get the default CA certificate bundle, preferring those configured in
the standard OpenSSL environment variables before those provided by
certifi (if available)
"""
ca_certs = os.environ.get("SSL_CERT_FILE") or os.environ.get("SSL_CERT_DIR")

if ca_certs:
return ca_certs

try:
import certifi
except ImportError:
pass
else:
return certifi.where()
2 changes: 2 additions & 0 deletions opensearchpy/connection/base.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,5 @@ class Connection(object):
self, status_code: int, raw_data: str, content_type: Optional[str]
) -> NoReturn: ...
def _get_default_user_agent(self) -> str: ...
@staticmethod
def default_ca_certs() -> Optional[str]: ...
10 changes: 8 additions & 2 deletions opensearchpy/connection/http_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ class RequestsHttpConnection(Connection):
:arg use_ssl: use ssl for the connection if `True`
:arg verify_certs: whether to verify SSL certificates
:arg ssl_show_warn: show warning when verify certs is disabled
:arg ca_certs: optional path to CA bundle. By default standard requests'
bundle will be used.
:arg ca_certs: optional path to CA bundle. Defaults to configured OpenSSL
bundles from environment variables and then certifi before falling
back to the standard requests bundle to improve consistency with
other Connection implementations
:arg client_cert: path to the file containing the private key and the
certificate, or cert only if using client_key
:arg client_key: path to the file containing the private key if using
Expand Down Expand Up @@ -129,6 +131,10 @@ def __init__(
"You cannot pass CA certificates when verify SSL is off."
)
self.session.verify = ca_certs
elif verify_certs:
ca_certs = self.default_ca_certs()
if ca_certs:
self.session.verify = ca_certs

if not ssl_show_warn:
requests.packages.urllib3.disable_warnings()
Expand Down
12 changes: 1 addition & 11 deletions opensearchpy/connection/http_urllib3.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
# specific language governing permissions and limitations
# under the License.


import ssl
import time
import warnings
Expand All @@ -49,15 +48,6 @@
VERIFY_CERTS_DEFAULT = object()
SSL_SHOW_WARN_DEFAULT = object()

CA_CERTS = None

try:
import certifi

CA_CERTS = certifi.where()
except ImportError:
pass


def create_ssl_context(**kwargs):
"""
Expand Down Expand Up @@ -186,7 +176,7 @@ def __init__(
if ssl_show_warn is SSL_SHOW_WARN_DEFAULT:
ssl_show_warn = True

ca_certs = CA_CERTS if ca_certs is None else ca_certs
ca_certs = self.default_ca_certs() if ca_certs is None else ca_certs
if verify_certs:
if not ca_certs:
raise ImproperlyConfigured(
Expand Down
1 change: 0 additions & 1 deletion opensearchpy/helpers/test.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ from unittest import TestCase
from ..client import OpenSearch

OPENSEARCH_URL: str
CA_CERTS: str

def get_test_client(nowait: bool = ..., **kwargs: Any) -> OpenSearch: ...
def _get_version(version_string: str) -> Tuple[int, ...]: ...
Expand Down
20 changes: 20 additions & 0 deletions test_opensearchpy/test_async/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

from opensearchpy import AIOHttpConnection, __versionstr__
from opensearchpy.compat import reraise_exceptions
from opensearchpy.connection import Connection
from opensearchpy.exceptions import ConnectionError

pytestmark = pytest.mark.asyncio
Expand Down Expand Up @@ -246,6 +247,25 @@ def test_warns_if_using_non_default_ssl_kwargs_with_ssl_context(self):
== str(w[0].message)
)

@patch("ssl.SSLContext.load_verify_locations")
def test_uses_given_ca_certs(self, load_verify_locations, tmp_path):
path = tmp_path / "ca_certs.pem"
path.touch()
AIOHttpConnection(use_ssl=True, ca_certs=str(path))
load_verify_locations.assert_called_once_with(cafile=str(path))

@patch("ssl.SSLContext.load_verify_locations")
def test_uses_default_ca_certs(self, load_verify_locations):
AIOHttpConnection(use_ssl=True)
load_verify_locations.assert_called_once_with(
cafile=Connection.default_ca_certs()
)

@patch("ssl.SSLContext.load_verify_locations")
def test_uses_no_ca_certs(self, load_verify_locations):
AIOHttpConnection(use_ssl=True, verify_certs=False)
load_verify_locations.assert_not_called()

@patch("opensearchpy.connection.base.logger")
async def test_uncompressed_body_logged(self, logger):
con = await self._get_mock_connection(connection_params={"http_compress": True})
Expand Down
53 changes: 53 additions & 0 deletions test_opensearchpy/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@

from .test_cases import SkipTest, TestCase

try:
from pytest import MonkeyPatch
except ImportError: # Old version of pytest for 2.7 and 3.5
from _pytest.monkeypatch import MonkeyPatch


def gzip_decompress(data):
buf = gzip.GzipFile(fileobj=io.BytesIO(data), mode="rb")
Expand Down Expand Up @@ -155,6 +160,28 @@ def test_compatibility_accept_header(self):
finally:
os.environ.pop("ELASTIC_CLIENT_APIVERSIONING")

def test_ca_certs_ssl_cert_file(self):
cert = "/path/to/clientcert.pem"
with MonkeyPatch().context() as monkeypatch:
monkeypatch.setenv("SSL_CERT_FILE", cert)
assert Connection.default_ca_certs() == cert

def test_ca_certs_ssl_cert_dir(self):
cert = "/path/to/clientcert/dir"
with MonkeyPatch().context() as monkeypatch:
monkeypatch.setenv("SSL_CERT_DIR", cert)
assert Connection.default_ca_certs() == cert

def test_ca_certs_certifi(self):
import certifi

assert Connection.default_ca_certs() == certifi.where()

def test_no_ca_certs(self):
with MonkeyPatch().context() as monkeypatch:
monkeypatch.setitem(sys.modules, "certifi", None)
assert Connection.default_ca_certs() is None


class TestUrllib3Connection(TestCase):
def _get_mock_connection(self, connection_params={}, response_body=b"{}"):
Expand Down Expand Up @@ -394,6 +421,19 @@ def test_warns_if_using_non_default_ssl_kwargs_with_ssl_context(self):
str(w[0].message),
)

def test_uses_given_ca_certs(self):
path = "/path/to/my/ca_certs.pem"
c = Urllib3HttpConnection(use_ssl=True, ca_certs=path)
self.assertEqual(path, c.pool.ca_certs)

def test_uses_default_ca_certs(self):
c = Urllib3HttpConnection(use_ssl=True)
self.assertEqual(Connection.default_ca_certs(), c.pool.ca_certs)

def test_uses_no_ca_certs(self):
c = Urllib3HttpConnection(use_ssl=True, verify_certs=False)
self.assertIsNone(c.pool.ca_certs)

@patch("opensearchpy.connection.base.logger")
def test_uncompressed_body_logged(self, logger):
con = self._get_mock_connection(connection_params={"http_compress": True})
Expand Down Expand Up @@ -526,6 +566,19 @@ def test_uses_https_if_verify_certs_is_off(self):
self.assertEqual("GET", request.method)
self.assertEqual(None, request.body)

def test_uses_given_ca_certs(self):
path = "/path/to/my/ca_certs.pem"
c = RequestsHttpConnection(ca_certs=path)
self.assertEqual(path, c.session.verify)

def test_uses_default_ca_certs(self):
c = RequestsHttpConnection()
self.assertEqual(Connection.default_ca_certs(), c.session.verify)

def test_uses_no_ca_certs(self):
c = RequestsHttpConnection(verify_certs=False)
self.assertFalse(c.session.verify)

def test_nowarn_when_uses_https_if_verify_certs_is_off(self):
with warnings.catch_warnings(record=True) as w:
con = self._get_mock_connection(
Expand Down