Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@
)


# Cast SSL_CTX* to void*
def _cast_ssl_ctx_to_void_p_pyopenssl(ssl_ctx):
try:
import cffi
except ImportError as caught_exc:
raise exceptions.MutualTLSChannelError(
"cffi is required for pyOpenSSL ECP support."
) from caught_exc

return ctypes.cast(int(cffi.FFI().cast("intptr_t", ssl_ctx)), ctypes.c_void_p)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to use an unsigned ptr here (uintptr_t) to avoid overflow if the address of ssl_ctx has a most significant bit of "1"?



# Cast SSL_CTX* to void*
def _cast_ssl_ctx_to_void_p_stdlib(context):
if not issubclass(type(context), ssl.SSLContext):
Expand Down Expand Up @@ -281,7 +293,7 @@ def attach_to_ssl_context(self, ctx):
if not self._offload_lib.ConfigureSslContext(
self._sign_callback,
ctypes.c_char_p(self._cert),
_cast_ssl_ctx_to_void_p_stdlib(ctx),
_cast_ssl_ctx_to_void_p_pyopenssl(ctx._ctx._context),
):
Comment on lines 293 to 297

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To prevent security risks such as passphrase leakage from arbitrary duck-typed wrapper objects, we should enforce strict type checking on the SSL context instead of using duck typing. Additionally, to maintain backwards compatibility and avoid introducing breaking changes, we should gracefully return False (or fall back) instead of raising an exception if the context is not of the expected type.

Suggested change
if not self._offload_lib.ConfigureSslContext(
self._sign_callback,
ctypes.c_char_p(self._cert),
_cast_ssl_ctx_to_void_p_stdlib(ctx),
_cast_ssl_ctx_to_void_p_pyopenssl(ctx._ctx._context),
):
if not isinstance(ctx, OpenSSL.SSL.Context):
return False
ssl_ctx = ctx._ctx._context
if not self._offload_lib.ConfigureSslContext(
self._sign_callback,
ctypes.c_char_p(self._cert),
_cast_ssl_ctx_to_void_p_pyopenssl(ssl_ctx),
):
References
  1. When passing sensitive cryptographic material (such as private keys and passphrases) to an SSL context, enforce strict type checking (e.g., isinstance(ctx, ssl.SSLContext)) instead of duck typing. This prevents security risks, such as passphrase leakage, that could be introduced by arbitrary duck-typed wrapper objects.
  2. Do not replace historical graceful fallback behaviors (such as returning False/falling back to standard TLS) with exceptions if doing so would introduce breaking changes for downstream users and violate backwards compatibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this, and it seems like this is a false flag.
First, the recommended solution would not work, because ctx is a urllib3.contrib.pyopenssl.PyOpenSSLContext wrapper object (where the OpenSSL.SSL.Context instance lives at ctx._ctx). Enforcing isinstance(ctx, OpenSSL.SSL.Context) directly on ctx evaluates to False, which would disable ECP offload configuration entirely. Also attach_to_ssl_context is designed to raise exceptions.MutualTLSChannelError whenever ECP configuration fails. Transport callers (such as requests.py) expect an exception on failure and do not evaluate boolean return values, so returning False would cause setup failures to fail silently.

Second, there isn't really a security risk here. The SSL context is only used to pass OpenSSL's underlying C-level SSL_CTX* pointer (ctx._ctx._context) to the C++ offload library. Private key operations and signing are offloaded via _sign_callback (communicating with hardware/TPM), so no private keys or passphrases are stored on or accessed from ctx.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I think I understand the conclusion of this being, largely, a false flag - I do think there may be some validity here with regards to the existing change leading to the possibility of an AttributeError being raised and escaping. We probably should be more defensive about extracting this.

raise exceptions.MutualTLSChannelError(
"failed to configure ECP Offload SSL context"
Expand Down
25 changes: 17 additions & 8 deletions packages/google-auth/google/auth/transport/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@
from google.auth import exceptions
from google.auth import transport
from google.auth.transport import _mtls_helper
import google.auth.transport._mtls_helper
from google.oauth2 import service_account

try:
import OpenSSL.SSL # type: ignore

_OPENSSL_SSL_ERROR = (OpenSSL.SSL.Error,)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to worry about OpenSSL.crypto.Error?

except ImportError:
_OPENSSL_SSL_ERROR = () # type: ignore

_LOGGER = logging.getLogger(__name__)

_DEFAULT_TIMEOUT = 120 # in seconds
Expand Down Expand Up @@ -242,7 +248,7 @@ def __init__(self, cert, key, **kwargs):
ValueError,
RuntimeError,
TypeError,
) as exc:
) + _OPENSSL_SSL_ERROR as exc:
raise exceptions.MutualTLSChannelError(
"Failed to configure client certificate and key for mTLS."
) from exc
Expand Down Expand Up @@ -278,7 +284,7 @@ class _MutualTlsOffloadAdapter(requests.adapters.HTTPAdapter):
}

Raises:
ImportError: if certifi is not installed
ImportError: if certifi or pyOpenSSL is not installed
google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
creation failed for any reason.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or if cffi is not installed

"""
Expand All @@ -290,6 +296,11 @@ def __init__(self, enterprise_cert_file_path):
self.signer = _custom_tls_signer.CustomTlsSigner(enterprise_cert_file_path)
self.signer.load_libraries()

if not self.signer.should_use_provider():
import urllib3.contrib.pyopenssl

urllib3.contrib.pyopenssl.inject_into_urllib3()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this has a process-wide side effect where it permanently switches all subsequent HTTPS requests made anywhere across the Python process to PyOpenSSL.


poolmanager = create_urllib3_context()
poolmanager.load_verify_locations(cafile=certifi.where())
self.signer.attach_to_ssl_context(poolmanager)
Expand Down Expand Up @@ -467,7 +478,7 @@ def configure_mtls_channel(self, client_cert_callback=None):
creation failed for any reason. The existing session state (such
as adapter mounts) remains unmodified if this error is raised.
"""
use_client_cert = google.auth.transport._mtls_helper.check_use_client_cert()
use_client_cert = _mtls_helper.check_use_client_cert()
if not use_client_cert:
return

Expand All @@ -476,9 +487,7 @@ def configure_mtls_channel(self, client_cert_callback=None):
is_mtls,
cert,
key,
) = google.auth.transport._mtls_helper.get_client_cert_and_key(
client_cert_callback
)
) = _mtls_helper.get_client_cert_and_key(client_cert_callback)

old_adapter = self.adapters.get("https://")

Expand Down Expand Up @@ -537,7 +546,7 @@ def configure_mtls_channel(self, client_cert_callback=None):
ImportError,
OSError,
ValueError,
) as caught_exc:
) + _OPENSSL_SSL_ERROR as caught_exc:
new_exc = exceptions.MutualTLSChannelError(caught_exc)
raise new_exc from caught_exc

Expand Down
18 changes: 16 additions & 2 deletions packages/google-auth/google/auth/transport/urllib3.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@
from google.auth.transport import _mtls_helper
from google.oauth2 import service_account

try:
import OpenSSL.SSL # type: ignore

_OPENSSL_SSL_ERROR = (OpenSSL.SSL.Error,)
except ImportError:
_OPENSSL_SSL_ERROR = () # type: ignore

if version.parse(urllib3.__version__) >= version.parse("2.0.0"): # pragma: NO COVER
RequestMethods = urllib3._request_methods.RequestMethods # type: ignore
else: # pragma: NO COVER
Expand Down Expand Up @@ -194,7 +201,14 @@ def _make_mutual_tls_http(cert, key):
keyfile=key_path,
password=password,
)
except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError, TypeError) as exc:
except (
ssl.SSLError,
OSError,
IOError,
ValueError,
RuntimeError,
TypeError,
) + _OPENSSL_SSL_ERROR as exc:
raise exceptions.MutualTLSChannelError(
"Failed to configure client certificate and key for mTLS."
) from exc
Expand Down Expand Up @@ -368,7 +382,7 @@ def configure_mtls_channel(self, client_cert_callback=None):
ImportError,
OSError,
ValueError,
) as caught_exc:
) + _OPENSSL_SSL_ERROR as caught_exc:
new_exc = exceptions.MutualTLSChannelError(caught_exc)
raise new_exc from caught_exc

Expand Down
1 change: 1 addition & 0 deletions packages/google-auth/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def mypy(session):
session.install(
"mypy",
"types-certifi",
"types-cffi",
"types-freezegun",
"types-requests",
"types-setuptools",
Expand Down
3 changes: 2 additions & 1 deletion packages/google-auth/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

reauth_extra_require = ["pyu2f>=0.1.5"]

enterprise_cert_extra_require = cryptography_base_require
enterprise_cert_extra_require = ["pyopenssl>=20.0.0", "cffi>=1.0.0"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Replacing cryptography_base_require entirely with ["pyopenssl>=20.0.0", "cffi>=1.0.0"] removes the cryptography dependency from the enterprise_cert extra. Since the enterprise certificate functionality still relies on cryptography for parsing and handling certificates, we should combine them instead of replacing.

enterprise_cert_extra_require = cryptography_base_require + [
    "pyopenssl>=20.0.0",
    "cffi>=1.0.0",
]


urllib3_extra_require = [
"urllib3 >= 1.26.15, < 3.0.0",
Expand All @@ -65,6 +65,7 @@
*reauth_extra_require,
"responses",
*urllib3_extra_require,
*enterprise_cert_extra_require,
# Async Dependencies
*aiohttp_extra_require,
"aioresponses",
Expand Down
20 changes: 18 additions & 2 deletions packages/google-auth/tests/transport/test__custom_tls_signer.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,15 @@ def test_get_cert():
assert len(mock_cert) == mock_cert_len


def test_custom_tls_signer():
@pytest.fixture
def inject_pyopenssl():
urllib3_pyopenssl = pytest.importorskip("urllib3.contrib.pyopenssl")
urllib3_pyopenssl.inject_into_urllib3()
yield
urllib3_pyopenssl.extract_from_urllib3()


def test_custom_tls_signer(inject_pyopenssl):
offload_lib = mock.MagicMock()
signer_lib = mock.MagicMock()

Expand Down Expand Up @@ -238,7 +246,9 @@ def test_custom_tls_signer_failed_to_attach():
signer_object._sign_callback = mock.MagicMock()
signer_object._cert = b"mock cert"
signer_object._offload_lib.ConfigureSslContext.return_value = False
signer_object.attach_to_ssl_context(ssl.SSLContext())
ctx = mock.Mock()
ctx._ctx._context = 123456
signer_object.attach_to_ssl_context(ctx)
assert excinfo.match("failed to configure ECP Offload SSL context")


Expand Down Expand Up @@ -366,3 +376,9 @@ def test_cast_ssl_ctx_to_void_p_stdlib_mock_error():
TypeError, match="context must be an instance of ssl.SSLContext, not a mock"
):
_custom_tls_signer._cast_ssl_ctx_to_void_p_stdlib(context)


def test_cast_ssl_ctx_to_void_p_pyopenssl(inject_pyopenssl):
context = create_urllib3_context()
res = _custom_tls_signer._cast_ssl_ctx_to_void_p_pyopenssl(context._ctx._context)
assert isinstance(res, ctypes.c_void_p)
34 changes: 33 additions & 1 deletion packages/google-auth/tests/transport/test_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,24 @@ def test_setup_error_raises_mutual_tls_channel_error(self, mock_secure_paths):
assert "Failed to configure client certificate" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, OSError)

@mock.patch("google.auth.transport.requests.create_urllib3_context")
def test_pyopenssl_error_raises_mtls_error(self, mock_create_context):
try:
import OpenSSL.SSL # type: ignore
except ImportError:
pytest.skip("pyOpenSSL not installed")

mock_context = mock.MagicMock()
mock_context.load_cert_chain.side_effect = OpenSSL.SSL.Error(
"OpenSSL cert load failure"
)
mock_create_context.return_value = mock_context

with pytest.raises(exceptions.MutualTLSChannelError) as exc_info:
google.auth.transport.requests._MutualTlsAdapter(b"cert", b"key")
assert "Failed to configure client certificate" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, OpenSSL.SSL.Error)


def make_response(status=http_client.OK, data=None):
response = requests.Response()
Expand Down Expand Up @@ -676,8 +694,11 @@ def test_configure_mtls_channel_cert_loading_exceptions(
"CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH": "",
},
)
@mock.patch(
"google.auth.transport._mtls_helper._get_cert_config_path", return_value=None
)
def test_configure_mtls_channel_without_client_cert_env(
self, get_client_cert_and_key
self, mock_get_cert_config_path, get_client_cert_and_key
):
env_to_patch = {
environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "",
Expand Down Expand Up @@ -1045,6 +1066,16 @@ def test_configure_mtls_channel_subsequent_disabled(self):


class TestMutualTlsOffloadAdapter(object):
@pytest.fixture(autouse=True)
def teardown_pyopenssl(self):
yield
try:
from urllib3.contrib.pyopenssl import extract_from_urllib3

extract_from_urllib3()
except ImportError:
pass

@mock.patch.object(requests.adapters.HTTPAdapter, "init_poolmanager")
@mock.patch.object(requests.adapters.HTTPAdapter, "proxy_manager_for")
@mock.patch.object(
Expand All @@ -1061,6 +1092,7 @@ def test_success(
mock_proxy_manager_for,
mock_init_poolmanager,
):
pytest.importorskip("urllib3.contrib.pyopenssl")
enterprise_cert_file_path = "/path/to/enterprise/cert/json"
adapter = google.auth.transport.requests._MutualTlsOffloadAdapter(
enterprise_cert_file_path
Expand Down
26 changes: 25 additions & 1 deletion packages/google-auth/tests/transport/test_urllib3.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ def test_setup_error_raises_mutual_tls_channel_error(self, mock_secure_paths):
assert "Failed to configure client certificate" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, OSError)

@mock.patch(
"google.auth.transport.urllib3.urllib3.util.ssl_.create_urllib3_context",
autospec=True,
)
def test_pyopenssl_error_raises_mtls_error(self, mock_create_context):
try:
import OpenSSL.SSL # type: ignore
except ImportError:
pytest.skip("pyOpenSSL not installed")

mock_context = mock.MagicMock()
mock_context.load_cert_chain.side_effect = OpenSSL.SSL.Error(
"OpenSSL cert load failure"
)
mock_create_context.return_value = mock_context

with pytest.raises(exceptions.MutualTLSChannelError) as exc_info:
google.auth.transport.urllib3._make_mutual_tls_http(b"cert", b"key")
assert "Failed to configure client certificate" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, OpenSSL.SSL.Error)


class TestAuthorizedHttp(object):
TEST_URL = "http://example.com"
Expand Down Expand Up @@ -397,8 +418,11 @@ def test_configure_mtls_channel_cert_loading_exceptions(
"CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH": "",
},
)
@mock.patch(
"google.auth.transport._mtls_helper._get_cert_config_path", return_value=None
)
def test_configure_mtls_channel_without_client_cert_env(
self, get_client_cert_and_key
self, mock_get_cert_config_path, get_client_cert_and_key
):
callback = mock.Mock()

Expand Down
Loading