The Advisory Says Patched in 0.45.2; the Code Says 0.58.3: SAP’s CVSS-10 OAuth2 SSRF in the Cloud SDK for Python

On 13 October 2026, NVD published CVE-2026-89111 — a CVSS 10.0 server-side request forgery in the SAP Cloud SDK for Python, the official programmatic toolkit for building AI agents and Python applications on SAP Business Technology Platform services. An attacker who controls the tenant_subdomain parameter can redirect OAuth2 token requests to an attacker-controlled server, and the victim application posts its OAuth2 client credentials there. No privileges required, no user interaction, scope changed: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N. The weakness is CWE-918, and the record’s references point at SAP’s own GitHub security advisory, GHSA-4mv3-jxxm-9cx2, published the same day.

That much is a bad day for multi-tenant BTP applications. The more interesting finding is in the version arithmetic. The advisory states the vulnerable range as below 0.45.2 with 0.45.2 as the patched version. We read the repository history first-hand, and the fix for this CVE shipped in three instalments across seven weeks — the last of them four days before the CVE was published. Anyone running a version between 0.45.2 and 0.55.0 who reads the advisory literally will conclude they are patched. For one reachable code path, they are not.

The sink: one str.replace call with no URL awareness

The vulnerable primitive is visible in XsuaaAuthProvider._fetch_token as it stood at v0.55.0:

token_url = str(token_url).replace(str(identityzone), tenant_subdomain)

The subscriber tenant’s subdomain — a string the application accepts, in multi-tenant designs often derived from the incoming request — is substituted into the OAuth2 token URL with a plain string replacement. As the maintainer’s own fix description puts it, str.replace has no URL awareness: a value containing URL delimiters, for example evil.example/oauth/token?ignore=, changes the parsed authority of the token URL to an attacker-chosen hostname while moving the trusted authentication suffix into a query parameter. The binding’s client_id and client_secret are then posted to that endpoint, and — the detail that makes this a 10.0 rather than a curiosity — standard TLS verification does not block it, because it verifies the attacker-chosen authority rather than the intended one.

Three fixes, seven weeks, one advisory range

The repository, SAP/cloud-sdk-python, fixed this in three passes, all authored by the same maintainer:

  • 21 August 2026, PR #279, released as v0.45.2 — introduced the subdomain validator, an RFC 1123 DNS-label check, at three call sites: AgentGatewayClient._resolve_tenant_subdomain, destination.TokenProvider.get_token and dms.Auth._resolve_token_url. This is the version the advisory names as the patch.
  • 18 September 2026, PR #345, released as v0.55.1 — applied the same validator to XsuaaAuthProvider._fetch_token, the fourth call site, which until that commit forwarded the tenant string unchanged into the str.replace sink. The release notes name the evil.example/oauth/token?ignore= payload explicitly. The fix description states the exposure plainly: the tenant string accepted by every public Destination and service client method was reaching the URL substitution unvalidated.
  • 9 October 2026, PR #373, released as v0.58.3 — replaced the str.replace derivation itself with a URL-aware helper, _derive_tenant_token_url, which uses urllib.parse to swap only the leading DNS label of the hostname when it matches the identity zone, preserving scheme, port, path, query and fragment verbatim. The pre-existing validator already blocked authority-injection payloads, but the old code would also rewrite occurrences of the identity-zone string appearing in the URL path — semantically wrong and a latent injection surface.

The validator now enforced reads, in full: ^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$ — a single DNS label, no dots, slashes, spaces, underscores or leading and trailing hyphens, at most 63 characters, with None passing through so provider-zone defaults keep working.

So the advisory’s “patched in 0.45.2” is true of three call sites and false of the fourth. Between 0.45.2 and 0.55.0, the XsuaaAuthProvider token path — the path that posts client secrets — still interpolated an unvalidated tenant string into the token URL. The complete fix, validation plus parse-aware substitution, exists only from v0.58.3 onward. The CVE was published on 13 October; the last code change landed on 9 October. There was no window in which the public record described the actual state of the code.

Patch Day context, kept in proportion

CVE-2026-89111 is the headline of SAP’s October 2026 Security Patch Day, which addressed 20 CVEs on 13 October, four of them rated critical. The rest of the critical set sits outside this site’s AI focus but matters to anyone running SAP estates: CVE-2026-89110 (9.9, broken access control in NetWeaver Visual Composer letting low-privileged portal users invoke internal operations on connected remote systems), CVE-2026-89107 (9.9, S/4HANA) and CVE-2026-89108 (9.1, Manufacturing Integration and Intelligence). Secondary reporting notes no confirmed exploitation in the wild and no confirmed public proof-of-concept for any of them. The SDK flaw is the only one of the four that lives in AI-agent infrastructure, which is why it leads here.

What to do

  • Upgrade past 0.58.3, not just past 0.45.2. The advisory’s patched version does not cover the XsuaaAuthProvider path or the str.replace semantics. If your lockfile sits anywhere in 0.45.2–0.58.2, the record says you are safe and the code says otherwise. Verify with the artefact, not the range: check that your installed sap-cloud-sdk contains _derive_tenant_token_url in sap_cloud_sdk.core._tenant.
  • Apply the workaround if you cannot upgrade now. Both the advisory and SAP’s coverage agree: validate tenant_subdomain against a known allowlist of registered tenant subdomains before passing it to any SDK call. A tenant identifier that arrives inside an HTTP request is attacker input; treat the allowlist as a security boundary, not input hygiene.
  • Rotate OAuth2 client credentials for multi-tenant BTP applications. A successful exploit posts client_id and client_secret to a third party. If a tenant-controlled subdomain ever reached a vulnerable SDK version, the secret is compromised regardless of what logs show — the exfiltration is a legitimate-looking outbound token request.
  • For maintainers of multi-tenant SDKs: substitute URLs with a parser, never with string replacement. The three-fix sequence here is the general lesson. Validation at the call site is necessary and was correctly the first fix; but as long as str.replace remained the substitution mechanism, every future call site inherited the sink by default. Parse-aware construction makes the safe behaviour structural instead of something each caller must remember.

This is the same per-callsite pattern this site keeps finding: conductor-oss’s competent SSRF guard living as a private method in one service while the sibling MCP client fetches with no destination policy at all (five SSRF records, one orchestrator), and the reference MCP fetch server’s unpatched SSRF. Here the ending is happier — the project did centralise, in the third fix — but the advisory still describes the first fix as the whole story.

Verification note. The CVE identifier, NVD publication and modification timestamps, vulnStatus (Received), CVSS 3.1 vector and base score, CWE-918 assignment and reference URLs were read first-hand from the NVD 2.0 API. The advisory text, severity, affected and patched versions, reporter credit (SunandM) and publication timestamp were read first-hand from the GitHub advisory API for GHSA-4mv3-jxxm-9cx2. The vulnerable str.replace line was read directly from models.py at tag v0.55.0; the validator regex and _derive_tenant_token_url from _tenant.py at main HEAD; the three fix commits, PR titles, bodies, authorship and merge timestamps (PRs #279, #345, #373), the pyproject version bumps (0.45.2, 0.55.0→0.55.1, 0.58.2→0.58.3) and the v0.45.2, v0.55.1 and v0.58.3 release notes and publication timestamps were all read first-hand from the GitHub API. All quoted strings and payloads are verbatim. The Patch Day batch totals and the non-SDK CVE descriptions are carried from secondary reporting (BleepingComputer, SecurityOnline, fetched first-hand) and labelled as such; the Hacker News account was bot-blocked to us (HTTP 403) and is not relied on. Nothing was installed, no BTP application was deployed, no exploit was executed and no request was sent to any third-party deployment. The version-range critique and remediation ordering are our editorial analysis. We have no evidence of exploitation and imply none.

Sources: