Cross-Tenant Credential Disclosure: Iterable IDs Link Another Customer's API Secrets to Your Account
The Risk
The platform let one customer link another customer's stored integration credentials to their own account just by guessing a small number. Once linked, the platform handed back the actual login headers, the equivalent of the username, password, or token, in plain text. There was no warning, no approval, and no need for the victim's account or any of their secrets. Anyone with a normal account could harvest other customers' integration credentials by walking a list of small numbers.
The Vulnerability
The platform's integration component let a tenant create an HTTP destination and bind it to a stored authorization object (basic auth, bearer auth, or OAuth 2.0). When the destination was created, the request body included an authorization.id field. That ID was a small incrementing integer (the IDs observed on the test accounts were three consecutive numbers in the low hundreds).
The endpoint that created destinations did not check that the supplied authorization.id belonged to the calling tenant. A request from tenant B with tenant A's authorization ID was accepted and the destination was created, bound to tenant A's stored credentials. From there, the destination-listing and destination-test endpoints in the same component returned the materialized Authorization header (the bearer token, the base64-encoded basic auth, or the OAuth-derived bearer token) to tenant B with no ownership check either.
The Attack
The minimal handoff
The PoC was deliberately staged to make the size of the attacker's prior knowledge unambiguous. Tenant A created three demo connections (basic, bearer, OAuth 2.0). The platform returned three numeric authorization IDs, written to a file containing nothing else:
[292, 293, 294] Tenant B's PoC took only that file as input. No tenant A token, no password, no connection URL, no connection name, no auth-type metadata, no secret value.
Step 1, link tenant A's authorization to a tenant B destination
POST /integrations/v2/destinations
Authorization: Bearer <tenant B token>
{"name": "...", "destinationUrl": "...", "authorization": {"id": 292}} Response: 201. The destination was created on tenant B, bound to tenant A's authorization object.
Step 2, read the materialized authorization header
Calls to GET /integrations/destinations or POST /integrations/destinations/{id}/test returned the cleartext-equivalent Authorization header for the linked credential. Tenant B recovered:
- Basic auth:
triage_basic_user_demo:triage_basic_password_demo - Bearer auth:
Bearer TRIAGE_BEARER_TOKEN_DEMO - OAuth 2.0: a bearer token the backend minted using tenant A's OAuth client credentials
The values entered by tenant A and the values recovered by tenant B were an exact match across all three credential types. The backend itself ran the OAuth client-credentials exchange, using tenant A's stored client secret, then handed the resulting bearer token to tenant B.
Iterable integer IDs made enumeration trivial. An attacker could walk the ID space, link each to a fresh destination on their own account, and exfiltrate the materialized header.
The Impact
As any authenticated tenant on the platform, an attacker could:
- Recover cleartext-equivalent
Authorizationheaders for any other tenant's stored basic, bearer, or OAuth 2.0 credentials, given only a numeric authorization ID. - Cause the backend to mint a fresh OAuth bearer token using the victim's client credentials, then read that token. This makes the attacker authenticated against the victim's downstream third-party service for the lifetime of the issued token.
- Enumerate the entire authorization-ID space cheaply, since IDs were small consecutive integers.
For an enterprise SaaS where tenants store credentials for downstream integrations (CRMs, ticketing systems, finance APIs, internal services behind partner authentication), this is a multi-tenant credential broker leaking other customers' downstream identities to anyone with a paid account.
Remediation
- Enforce tenant ownership on every authorization-object reference accepted by the destinations endpoint. Reject any request where the supplied
authorization.iddoes not belong to the calling tenant. Expected behavior:403 Forbiddenor404 Not Foundfor cross-tenant references. - Apply the same ownership check on every endpoint that materializes an
Authorizationheader from a stored authorization object, including the destination-list and destination-test endpoints. - Replace iterable integer authorization IDs with non-guessable opaque identifiers (UUIDs) so that even a regression in the ownership check does not give attackers a cheap enumeration path.
- Audit logs for cross-tenant authorization-ID references during the exposure window. Notify any tenant whose authorization objects were linked to destinations they did not own, and rotate the underlying credentials.