From 8a4d73615940ef4623a579b03149ac5d30a0b8bf Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Mon, 5 Oct 2026 13:28:47 -0300 Subject: [PATCH 1/4] fix: improve url injection safeguards --- src/sap_cloud_sdk/core/_tenant.py | 32 ++++++++++++++ .../core/protocol/http/models.py | 6 ++- tests/core/unit/test_http_client.py | 22 ++++++++++ tests/core/unit/test_tenant.py | 42 ++++++++++++++++++- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/core/_tenant.py b/src/sap_cloud_sdk/core/_tenant.py index 1bb4b1ec..4bf3dbb0 100644 --- a/src/sap_cloud_sdk/core/_tenant.py +++ b/src/sap_cloud_sdk/core/_tenant.py @@ -1,4 +1,5 @@ import re +from urllib.parse import urlparse, urlunparse _SUBDOMAIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$") @@ -18,3 +19,34 @@ def _validate_tenant_subdomain(tenant_subdomain: str | None) -> None: return if not _SUBDOMAIN_RE.fullmatch(tenant_subdomain): raise ValueError(f"Invalid tenant_subdomain: {tenant_subdomain!r}") + + +def _derive_tenant_token_url( + token_url: str, + identityzone: str, + tenant_subdomain: str, +) -> str: + """Return *token_url* with the first hostname label replaced by *tenant_subdomain*. + + Only the leading DNS label of the hostname is replaced when it equals + *identityzone*. All other URL components (scheme, port, path, query, + fragment) are preserved verbatim. If the first label does not match + *identityzone*, the original URL is returned unchanged. + + Args: + token_url: The configured OAuth2 token endpoint URL. + identityzone: Provider identity zone label from the service binding. + tenant_subdomain: Validated single-label tenant identifier. + + Returns: + The derived token URL with only the first hostname label swapped. + """ + parsed = urlparse(token_url) + host = parsed.hostname or "" + first_label, sep, rest = host.partition(".") + if sep and first_label == identityzone: + new_netloc = f"{tenant_subdomain}.{rest}" + if parsed.port is not None: + new_netloc = f"{new_netloc}:{parsed.port}" + return urlunparse(parsed._replace(netloc=new_netloc)) + return token_url diff --git a/src/sap_cloud_sdk/core/protocol/http/models.py b/src/sap_cloud_sdk/core/protocol/http/models.py index e8e68014..01b6df24 100644 --- a/src/sap_cloud_sdk/core/protocol/http/models.py +++ b/src/sap_cloud_sdk/core/protocol/http/models.py @@ -13,7 +13,7 @@ from oauthlib.oauth2 import BackendApplicationClient from requests_oauthlib import OAuth2Session -from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain, _derive_tenant_token_url logger = logging.getLogger(__name__) @@ -112,7 +112,9 @@ def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session: and token_url is not None ): _validate_tenant_subdomain(tenant_subdomain) - token_url = str(token_url).replace(str(identityzone), tenant_subdomain) + token_url = _derive_tenant_token_url( + str(token_url), str(identityzone), tenant_subdomain + ) client = BackendApplicationClient(client_id=str(self._config.client_id)) oauth = OAuth2Session(client=client) diff --git a/tests/core/unit/test_http_client.py b/tests/core/unit/test_http_client.py index 46c7fd4d..83d4c997 100644 --- a/tests/core/unit/test_http_client.py +++ b/tests/core/unit/test_http_client.py @@ -284,3 +284,25 @@ def test_valid_subdomain_replaces_identityzone_in_token_url(self): provider._fetch_token("tenant-123") call_kwargs = mock_session.fetch_token.call_args[1] assert call_kwargs["token_url"] == "https://tenant-123.authentication.region/oauth/token" + + def test_identityzone_in_path_is_not_replaced(self): + """str.replace would corrupt the URL if identityzone appears in the path too.""" + cfg = MagicMock() + # token_url whose path also contains the identityzone value + cfg.token_url = "https://provider-zone.authentication.region/provider-zone/token" + cfg.identityzone = "provider-zone" + cfg.client_id = "cid" + cfg.client_secret = "csecret" + factory = MagicMock(return_value=cfg) + factory.has_changed = MagicMock(return_value=False) + provider = XsuaaAuthProvider(factory) + + with patch("sap_cloud_sdk.core.protocol.http.models.OAuth2Session") as mock_oauth_cls: + mock_session = MagicMock() + mock_oauth_cls.return_value = mock_session + mock_session.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} + provider._fetch_token("tenant-abc") + + call_kwargs = mock_session.fetch_token.call_args[1] + # Only the first hostname label must be replaced; path segment untouched + assert call_kwargs["token_url"] == "https://tenant-abc.authentication.region/provider-zone/token" diff --git a/tests/core/unit/test_tenant.py b/tests/core/unit/test_tenant.py index b1d259d4..dc441a41 100644 --- a/tests/core/unit/test_tenant.py +++ b/tests/core/unit/test_tenant.py @@ -2,7 +2,7 @@ import pytest -from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain, _derive_tenant_token_url class TestValidateTenantSubdomain: @@ -36,3 +36,43 @@ def test_invalid_subdomains_raise_value_error(self, invalid, description): def test_none_is_a_no_op(self): _validate_tenant_subdomain(None) # must not raise + + +class TestDeriveTenantTokenUrl: + BASE = "https://provider-zone.authentication.eu10.hana.ondemand.com/oauth/token" + IZ = "provider-zone" + + def test_replaces_first_label_only(self): + result = _derive_tenant_token_url(self.BASE, self.IZ, "tenant-123") + assert result == "https://tenant-123.authentication.eu10.hana.ondemand.com/oauth/token" + + def test_scheme_preserved(self): + result = _derive_tenant_token_url(self.BASE, self.IZ, "tenant-123") + assert result.startswith("https://") + + def test_path_preserved(self): + result = _derive_tenant_token_url(self.BASE, self.IZ, "tenant-123") + assert result.endswith("/oauth/token") + + def test_identityzone_in_path_not_replaced(self): + # Even if identityzone value appears in the path, it must not be touched + url = f"https://provider-zone.auth.region/{self.IZ}/token" + result = _derive_tenant_token_url(url, self.IZ, "tenant-abc") + assert result == f"https://tenant-abc.auth.region/{self.IZ}/token" + + def test_port_preserved_when_present(self): + url = "https://provider-zone.authentication.region:8443/oauth/token" + result = _derive_tenant_token_url(url, self.IZ, "tenant-123") + assert result == "https://tenant-123.authentication.region:8443/oauth/token" + + def test_no_match_returns_original_url(self): + url = "https://other-zone.authentication.region/oauth/token" + result = _derive_tenant_token_url(url, self.IZ, "tenant-123") + assert result == url + + def test_single_label_hostname_returns_original(self): + # token_url with no dots in host — nothing to replace + url = "https://provider-zone/oauth/token" + result = _derive_tenant_token_url(url, self.IZ, "tenant-123") + assert result == url + From 3d05a2bb013dbeb08ac55636efc311dbd2243655 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Mon, 5 Oct 2026 14:40:12 -0300 Subject: [PATCH 2/4] bump: version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b3a151bd..9f45a831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.57.2" +version = "0.58.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From dff49773dd71335ccc3fb100af5c02fdb1acae9a Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Mon, 5 Oct 2026 14:40:35 -0300 Subject: [PATCH 3/4] fix: fix lint --- tests/core/unit/test_tenant.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core/unit/test_tenant.py b/tests/core/unit/test_tenant.py index dc441a41..8839b829 100644 --- a/tests/core/unit/test_tenant.py +++ b/tests/core/unit/test_tenant.py @@ -75,4 +75,3 @@ def test_single_label_hostname_returns_original(self): url = "https://provider-zone/oauth/token" result = _derive_tenant_token_url(url, self.IZ, "tenant-123") assert result == url - From 06039c18480227a47c107dad37aefbc7e9bd81cd Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Mon, 5 Oct 2026 15:13:05 -0300 Subject: [PATCH 4/4] fix: fix lint --- src/sap_cloud_sdk/core/protocol/http/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/core/protocol/http/models.py b/src/sap_cloud_sdk/core/protocol/http/models.py index 01b6df24..f5e0e09f 100644 --- a/src/sap_cloud_sdk/core/protocol/http/models.py +++ b/src/sap_cloud_sdk/core/protocol/http/models.py @@ -13,7 +13,10 @@ from oauthlib.oauth2 import BackendApplicationClient from requests_oauthlib import OAuth2Session -from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain, _derive_tenant_token_url +from sap_cloud_sdk.core._tenant import ( + _validate_tenant_subdomain, + _derive_tenant_token_url, +) logger = logging.getLogger(__name__)