Skip to content

Commit f217bfc

Browse files
committed
fix(auth): parse hostname for mTLS and PSC endpoint certificate rotation (#18147)
* Isolate hostname using urllib.parse.urlsplit in _mtls_helper.is_mtls_endpoint * Eliminate false positives on non-mTLS URLs containing mtls substrings in paths/queries * Add support for Private Service Connect (*.p.googleapis.com) custom mTLS endpoints * Update AuthorizedSession and AuthorizedHttp to use shared is_mtls_endpoint helper * Add comprehensive unit tests in test__mtls_helper, test_requests, and test_urllib3 Fixes #18147 Follow-up to #17928
1 parent 92008e2 commit f217bfc

6 files changed

Lines changed: 223 additions & 22 deletions

File tree

‎packages/google-auth/google/auth/transport/_mtls_helper.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import sys
2525
import tempfile
2626
from typing import cast, Generator, List, Optional, Tuple, Union
27+
from urllib.parse import urlsplit
2728

2829
from google.auth import _agent_identity_utils
2930
from google.auth import _cloud_sdk
@@ -840,3 +841,42 @@ def call_client_cert_callback():
840841
generate_encrypted_key=True
841842
)
842843
return cert_bytes, key_bytes
844+
845+
846+
_MTLS_HOST_SUFFIXES = (
847+
".mtls.googleapis.com",
848+
".mtls.sandbox.googleapis.com",
849+
".p.googleapis.com",
850+
)
851+
_MTLS_EXACT_HOSTS = (
852+
"mtls.googleapis.com",
853+
"mtls.sandbox.googleapis.com",
854+
)
855+
856+
857+
def is_mtls_endpoint(url: Optional[Union[str, bytes]]) -> bool:
858+
"""Checks if the given URL corresponds to an mTLS or Private Service Connect (PSC) endpoint.
859+
860+
Args:
861+
url (Optional[Union[str, bytes]]): The request URL.
862+
863+
Returns:
864+
bool: True if the URL targets an mTLS or PSC endpoint, False otherwise.
865+
"""
866+
if not url:
867+
return False
868+
if isinstance(url, bytes):
869+
try:
870+
url = url.decode("utf-8")
871+
except (UnicodeDecodeError, AttributeError):
872+
return False
873+
try:
874+
hostname = urlsplit(url).hostname
875+
except (ValueError, TypeError, AttributeError):
876+
return False
877+
878+
if not hostname:
879+
return False
880+
881+
hostname = hostname.lower()
882+
return hostname in _MTLS_EXACT_HOSTS or hostname.endswith(_MTLS_HOST_SUFFIXES)

‎packages/google-auth/google/auth/transport/requests.py‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -647,13 +647,7 @@ def request(
647647
):
648648
# Handle unauthorized permission error(401 status code)
649649
if response.status_code == http_client.UNAUTHORIZED:
650-
MTLS_URL_PREFIXES = [
651-
"mtls.googleapis.com",
652-
"mtls.sandbox.googleapis.com",
653-
]
654-
use_mtls = self.is_mtls and any(
655-
prefix in url for prefix in MTLS_URL_PREFIXES
656-
)
650+
use_mtls = self.is_mtls and _mtls_helper.is_mtls_endpoint(url)
657651
if use_mtls:
658652
(
659653
call_cert_bytes,

‎packages/google-auth/google/auth/transport/urllib3.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,6 @@ def urlopen(self, method, url, body=None, headers=None, **kwargs):
409409
if headers is None:
410410
headers = self.headers
411411

412-
use_mtls = False
413-
if self._is_mtls:
414-
MTLS_URL_PREFIXES = ["mtls.googleapis.com", "mtls.sandbox.googleapis.com"]
415-
use_mtls = any([prefix in url for prefix in MTLS_URL_PREFIXES])
416-
417412
# Make a copy of the headers. They will be modified by the credentials
418413
# and we want to pass the original headers if we recurse.
419414
request_headers = headers.copy()
@@ -436,6 +431,7 @@ def urlopen(self, method, url, body=None, headers=None, **kwargs):
436431
and _credential_refresh_attempt < self._max_refresh_attempts
437432
):
438433
if response.status == http_client.UNAUTHORIZED:
434+
use_mtls = self._is_mtls and _mtls_helper.is_mtls_endpoint(url)
439435
if use_mtls:
440436
(
441437
call_cert_bytes,

‎packages/google-auth/tests/transport/test__mtls_helper.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1888,3 +1888,54 @@ def test_remove_oserror_ignored(
18881888
mock_fh.flush.assert_called_once()
18891889
mock_fsync.assert_called_once()
18901890
mock_remove.assert_called_once_with("/path/to/secret")
1891+
1892+
1893+
class TestIsMtlsEndpoint(object):
1894+
@pytest.mark.parametrize(
1895+
"url",
1896+
[
1897+
"https://mtls.googleapis.com",
1898+
"https://mtls.googleapis.com/",
1899+
"https://mtls.googleapis.com/v1/projects",
1900+
"https://mtls.sandbox.googleapis.com",
1901+
"https://mtls.sandbox.googleapis.com/v1/projects",
1902+
"https://pubsub.mtls.googleapis.com",
1903+
"https://pubsub.mtls.googleapis.com/v1/projects/my-project",
1904+
"https://storage.mtls.sandbox.googleapis.com/b/my-bucket",
1905+
"https://my-service.us-east1.rep.mtls.googleapis.com/v1",
1906+
"https://my-service.us-east1.rep.mtls.sandbox.googleapis.com/v1",
1907+
"https://storage.p.googleapis.com/b/my-bucket",
1908+
"https://my-custom-endpoint.p.googleapis.com/v1",
1909+
"https://my-service.us-east1.p.googleapis.com/v1",
1910+
"HTTP://PUBSUB.MTLS.GOOGLEAPIS.COM/V1",
1911+
b"https://pubsub.mtls.googleapis.com",
1912+
b"https://storage.p.googleapis.com/b/my-bucket",
1913+
],
1914+
)
1915+
def test_is_mtls_endpoint_true(self, url):
1916+
assert _mtls_helper.is_mtls_endpoint(url) is True
1917+
1918+
@pytest.mark.parametrize(
1919+
"url",
1920+
[
1921+
"https://storage.googleapis.com",
1922+
"https://storage.googleapis.com/bucket/mtls.googleapis.com",
1923+
"https://logging.googleapis.com/v2/entries?filter=mtls.googleapis.com",
1924+
"https://logging.googleapis.com/v2/entries?filter=mtls.sandbox.googleapis.com",
1925+
"https://logging.googleapis.com/v2/entries?filter=service.p.googleapis.com",
1926+
"https://example.com/mtls.googleapis.com",
1927+
"https://fake-mtls.googleapis.com.attacker.com/v1",
1928+
"https://fake-p.googleapis.com.attacker.com/v1",
1929+
"http://localhost:8080/",
1930+
"http://localhost:8080/mtls.googleapis.com",
1931+
b"https://storage.googleapis.com",
1932+
b"https://storage.googleapis.com/bucket/mtls.googleapis.com",
1933+
b"\xff\xfeinvalid",
1934+
"",
1935+
None,
1936+
123,
1937+
"not a url",
1938+
],
1939+
)
1940+
def test_is_mtls_endpoint_false(self, url):
1941+
assert _mtls_helper.is_mtls_endpoint(url) is False

‎packages/google-auth/tests/transport/test_requests.py‎

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,9 @@ def test_configure_mtls_channel_cert_loading_exceptions(
664664

665665
assert not auth_session.is_mtls
666666

667+
@mock.patch(
668+
"google.auth.transport._mtls_helper._get_cert_config_path", return_value=None
669+
)
667670
@mock.patch(
668671
"google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True
669672
)
@@ -677,7 +680,7 @@ def test_configure_mtls_channel_cert_loading_exceptions(
677680
},
678681
)
679682
def test_configure_mtls_channel_without_client_cert_env(
680-
self, get_client_cert_and_key
683+
self, get_client_cert_and_key, mock_get_cert_config_path
681684
):
682685
env_to_patch = {
683686
environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "",
@@ -937,9 +940,19 @@ def test_cert_rotation_logic_skipped_on_other_refresh_status_codes(self):
937940
# Assert mTLS check logic was SKIPPED (Inner Check was False)
938941
assert not mock_helper.check_parameters_for_unauthorized_response.called
939942

940-
def test_cert_rotation_skipped_on_non_mtls_url(self):
943+
@pytest.mark.parametrize(
944+
"non_mtls_url",
945+
[
946+
"http://example.com/",
947+
"https://storage.googleapis.com/bucket/mtls.googleapis.com",
948+
"https://logging.googleapis.com/v2/entries?filter=mtls.googleapis.com",
949+
"https://example.com/mtls.sandbox.googleapis.com",
950+
],
951+
)
952+
def test_cert_rotation_skipped_on_non_mtls_url(self, non_mtls_url):
941953
"""
942-
Tests that mTLS cert rotation is skipped on a non-mTLS URL even if
954+
Tests that mTLS cert rotation is skipped on non-mTLS URLs (including
955+
those containing mTLS substrings in paths/query parameters) even if
943956
mTLS is enabled and an UNAUTHORIZED (401) response is received.
944957
"""
945958
credentials = mock.Mock(wraps=CredentialsStub())
@@ -953,19 +966,53 @@ def test_cert_rotation_skipped_on_non_mtls_url(self):
953966
authed_session = google.auth.transport.requests.AuthorizedSession(
954967
credentials, refresh_timeout=60
955968
)
956-
authed_session.mount(self.TEST_URL, adapter)
969+
authed_session.mount("https://", adapter)
970+
authed_session.mount("http://", adapter)
957971
authed_session._is_mtls = True
972+
authed_session._cached_cert = b"cached_cert"
958973

959-
with mock.patch(
960-
"google.auth.transport.requests._mtls_helper", autospec=True
961-
) as mock_helper:
962-
authed_session.request("GET", self.TEST_URL)
974+
with mock.patch.object(
975+
google.auth.transport._mtls_helper,
976+
"check_parameters_for_unauthorized_response",
977+
) as mock_check_params:
978+
authed_session.request("GET", non_mtls_url)
963979

964980
# Assert refresh happened
965981
assert credentials.refresh.called
966982

967983
# Assert mTLS check logic was SKIPPED
968-
assert not mock_helper.check_parameters_for_unauthorized_response.called
984+
assert not mock_check_params.called
985+
986+
def test_cert_rotation_triggered_on_psc_url(self):
987+
"""
988+
Tests that mTLS cert rotation IS triggered on a Private Service Connect
989+
(PSC) mTLS endpoint when an UNAUTHORIZED (401) response is received.
990+
"""
991+
credentials = mock.Mock(wraps=CredentialsStub())
992+
adapter = AdapterStub(
993+
[
994+
make_response(status=http_client.UNAUTHORIZED),
995+
make_response(status=http_client.OK),
996+
]
997+
)
998+
psc_url = "https://storage.p.googleapis.com/b/my-bucket"
999+
authed_session = google.auth.transport.requests.AuthorizedSession(
1000+
credentials, refresh_timeout=60
1001+
)
1002+
authed_session.mount(psc_url, adapter)
1003+
authed_session._is_mtls = True
1004+
authed_session._cached_cert = b"cached_cert"
1005+
1006+
with mock.patch.object(
1007+
google.auth.transport._mtls_helper,
1008+
"check_parameters_for_unauthorized_response",
1009+
return_value=(b"new_cert", b"new_key", "old_fp", "old_fp"),
1010+
) as mock_check_params:
1011+
authed_session.request("GET", psc_url)
1012+
1013+
# Assert mTLS check logic was called on PSC endpoint
1014+
mock_check_params.assert_called_once()
1015+
assert credentials.refresh.called
9691016

9701017
def test_configure_mtls_channel_subsequent_failure(self):
9711018
# 1. Setup successful mTLS configuration

‎packages/google-auth/tests/transport/test_urllib3.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,9 @@ def test_configure_mtls_channel_cert_loading_exceptions(
385385

386386
assert not authed_http._is_mtls
387387

388+
@mock.patch(
389+
"google.auth.transport._mtls_helper._get_cert_config_path", return_value=None
390+
)
388391
@mock.patch(
389392
"google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True
390393
)
@@ -398,7 +401,7 @@ def test_configure_mtls_channel_cert_loading_exceptions(
398401
},
399402
)
400403
def test_configure_mtls_channel_without_client_cert_env(
401-
self, get_client_cert_and_key
404+
self, get_client_cert_and_key, mock_get_cert_config_path
402405
):
403406
callback = mock.Mock()
404407

@@ -655,6 +658,76 @@ def test_cert_rotation_logic_skipped_on_other_refresh_status_codes(self):
655658
# Assert mTLS check logic was SKIPPED (Inner Check was False)
656659
assert not mock_helper.check_parameters_for_unauthorized_response.called
657660

661+
@pytest.mark.parametrize(
662+
"non_mtls_url",
663+
[
664+
"http://example.com/",
665+
"https://storage.googleapis.com/bucket/mtls.googleapis.com",
666+
"https://logging.googleapis.com/v2/entries?filter=mtls.googleapis.com",
667+
"https://example.com/mtls.sandbox.googleapis.com",
668+
],
669+
)
670+
def test_cert_rotation_skipped_on_non_mtls_url(self, non_mtls_url):
671+
"""
672+
Tests that mTLS cert rotation is skipped on non-mTLS URLs (including
673+
those containing mTLS substrings in paths/query parameters) even if
674+
mTLS is enabled and an UNAUTHORIZED (401) response is received.
675+
"""
676+
credentials = mock.Mock(wraps=CredentialsStub())
677+
http = HttpStub(
678+
[
679+
ResponseStub(status=http_client.UNAUTHORIZED),
680+
ResponseStub(status=http_client.OK),
681+
]
682+
)
683+
authed_http = google.auth.transport.urllib3.AuthorizedHttp(
684+
credentials, http=http
685+
)
686+
authed_http._is_mtls = True
687+
authed_http._cached_cert = b"cached_cert"
688+
689+
with mock.patch.object(
690+
google.auth.transport._mtls_helper,
691+
"check_parameters_for_unauthorized_response",
692+
) as mock_check_params:
693+
authed_http.urlopen("GET", non_mtls_url)
694+
695+
# Assert refresh happened
696+
assert credentials.refresh.called
697+
698+
# Assert mTLS check logic was SKIPPED
699+
assert not mock_check_params.called
700+
701+
def test_cert_rotation_triggered_on_psc_url(self):
702+
"""
703+
Tests that mTLS cert rotation IS triggered on a Private Service Connect
704+
(PSC) mTLS endpoint when an UNAUTHORIZED (401) response is received.
705+
"""
706+
credentials = mock.Mock(wraps=CredentialsStub())
707+
http = HttpStub(
708+
[
709+
ResponseStub(status=http_client.UNAUTHORIZED),
710+
ResponseStub(status=http_client.OK),
711+
]
712+
)
713+
psc_url = "https://storage.p.googleapis.com/b/my-bucket"
714+
authed_http = google.auth.transport.urllib3.AuthorizedHttp(
715+
credentials, http=http
716+
)
717+
authed_http._is_mtls = True
718+
authed_http._cached_cert = b"cached_cert"
719+
720+
with mock.patch.object(
721+
google.auth.transport._mtls_helper,
722+
"check_parameters_for_unauthorized_response",
723+
return_value=(b"new_cert", b"new_key", "old_fp", "old_fp"),
724+
) as mock_check_params:
725+
authed_http.urlopen("GET", psc_url)
726+
727+
# Assert mTLS check logic was called on PSC endpoint
728+
mock_check_params.assert_called_once()
729+
assert credentials.refresh.called
730+
658731
@mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True)
659732
def test_configure_mtls_channel_subsequent_failure(self, mock_make_mutual_tls_http):
660733
callback = mock.Mock()

0 commit comments

Comments
 (0)