CVE case study
CVE-2026-59185: An Installation ID Is Not Proof of Ownership
How a client-supplied GitHub App installation ID crossed a tenant boundary, and why OAuth identity must bind the installation to the workspace.
This is a defensive case study of CVE-2026-59185, based on the coordinated public advisory. It explains the trust boundary, the failure mode, and the engineering fix without reproducing a weaponized exploit.
- Severity
- High · 8.5
- Scoring
- CVSS 3.1
- Weakness
- CWE-639 · CWE-862
- Affected
- Default development branch through 112ad80; no tagged affected release
- Remediation state
- Fixed in commit 835e405; no tagged fixed release is listed
- Advisory published
- 20 Jun 2026
Official vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N
Why it matters
A GitHub App installation identifier selects a powerful server-side credential context. It identifies an installation, but it does not prove that the caller owns or is authorized for the organization behind that installation.
identrail correctly bound the connection state to the initiating tenant, workspace, and project. The separate installation identifier, however, remained client-controlled. That mismatch allowed one authenticated tenant to ask the shared GitHub App to operate against another customer’s installation.
How I discovered it
I was auditing GitHub App connection flows for a recurring multi-tenant mistake: a server correctly validates OAuth state but independently trusts the final installation_id. I searched for completion routes that accepted that identifier from a request and then followed it to GitHub’s app-token endpoint.
identrail’s completion service strongly bound state to the caller’s tenant, workspace, and project. That was useful intent evidence. Immediately beside that check, however, the installation identifier received only a positive-number sanity check before it was persisted. The router also allowed a client-controlled header fallback.
The impact became concrete when I traced the stored identifier into ListInstallationRepositories() and the app-JWT request to /app/installations/{id}/access_tokens. A shared GitHub App can mint a token for any of its installations; therefore the missing ownership check turned a raw numeric selector into a cross-tenant credential context.
Trust boundary and root cause
The completion endpoint validated the OAuth-style state record but checked the supplied installation ID only for basic shape. It then persisted that value and used the application’s own credentials to mint an installation access token.
The missing relationship was between two facts: this workspace initiated the connection and this GitHub user is authorized for this specific installation. Validating either fact alone is insufficient in a multi-tenant integration.
Source-to-sink trace
- 01Client source
internal/api/router.goConnection completion reads
installation_idfrom the JSON body or a client-controlled header. - 02Correct state check
CompleteGitHubConnection()State is bound to tenant, workspace, and project, proving the intended scope model.
- 03Missing relationship
request.InstallationID validationThe identifier is checked only for being greater than zero, not for ownership by the authorizing GitHub user or organization.
- 04Credential sink
connectors/github/app.goThe application JWT mints an installation token for the supplied ID and uses it to list repositories.
- 01Legitimate start
An authenticated tenant begins a connection and receives state bound to its own workspace.
- 02Identifier substitution
Connection completion supplies a different customer’s GitHub App installation identifier.
- 03App token minting
The backend trusts that identifier and asks GitHub for an installation-scoped token.
- 04Persistent foreign binding
The victim installation becomes available through the attacker’s identrail workspace.
Safe proof of concept
Prerequisites
- Git and
rginstalled locally. - No identrail account, GitHub App credential, installation ID, or network target is required after cloning the public repository.
Step-by-step reproduction
- Clone the public repository and check out the affected revision from the vendor advisory.
- Locate the connection-completion route and verify that the identifier can originate in the request body or header.
- Inspect
CompleteGitHubConnection(): record the rigorous state-to-scope comparison, then the separateInstallationID <= 0check and persistence assignment. - Follow the identifier into the connector and confirm the app-JWT installation-token path.
- Run the synthetic model below. It proves the authorization invariant with two invented tenants and performs no HTTP request.
- Check out the public fix commit and rerun the same assertions. The ownership-verification path should now appear and the untrusted header fallback should be gone.
Offline affected-source assertions
git clone https://github.com/identrail/identrail.git identrail-cve-lab
cd identrail-cve-lab
git checkout 112ad80
rg -n 'X-GitHub-Installation-ID|github/connect/complete' internal/api/router.go
rg -n 'stateRecord.TenantID != scope.TenantID|request.InstallationID <= 0|InstallationID: request.InstallationID' internal/api/github_connect.go
rg -n '/app/installations/|access_tokens' internal/connectors/github/app.goSynthetic authorization-invariant PoC
SCOPE_A = ("tenant-a", "workspace-a", "project-a")
SCOPE_B = ("tenant-b", "workspace-b", "project-b")
OWNER = {1001: "tenant-a", 2002: "tenant-b"}
def vulnerable_complete(caller, state_scope, installation_id):
if state_scope != caller:
raise PermissionError("state does not belong to caller")
if installation_id <= 0:
raise ValueError("invalid installation id")
return {"tenant": caller[0], "installation_id": installation_id}
def fixed_complete(caller, state_scope, installation_id):
linked = vulnerable_complete(caller, state_scope, installation_id)
if OWNER.get(installation_id) != caller[0]:
raise PermissionError("installation does not belong to caller")
return linked
print("vulnerable:", vulnerable_complete(SCOPE_A, SCOPE_A, 2002))
try:
vulnerable_complete(SCOPE_A, SCOPE_B, 2002)
except PermissionError:
print("negative control: wrong state rejected")
try:
fixed_complete(SCOPE_A, SCOPE_A, 2002)
except PermissionError:
print("fixed model: foreign installation rejected")Fixed-source comparison
git checkout 835e40517509d6ef5405c27fbf14f579bedff0e7
rg -n 'X-GitHub-Installation-ID' internal/api/router.go
rg -n 'authoriz|installation.*account|account.*installation|oauth' internal/api/github_connect.goExpected evidence
- The affected revision shows a client source, strong state binding, a numeric-only installation check, direct persistence, and an app-token mint sink.
- There is no code path proving that the supplied installation belongs to the GitHub user or organization that completed authorization.
- The fixed revision adds authorizing-user verification and no longer relies on the raw header fallback.
The state comparison is the control: substitute a state belonging to another workspace in the code path and the service rejects it. That demonstrates the developers understood tenant binding, while the adjacent installation selector lacked equivalent binding.
At commit 835e405 or later, the source comparison should show authorizing-user verification before repository listing and persistence, plus removal of the untrusted header fallback. The synthetic model demonstrates the intended fail-closed invariant; it does not claim to runtime-test product persistence ordering.
Impact in context
The accepted advisory documents cross-tenant access to private repository inventory and metadata, plus repository-reading and posture operations available through the integration. Confidentiality impact is high because the backend acts with the installed app’s privileges rather than the caller’s normal GitHub session.
Exploitation requires an authenticated identrail tenant and knowledge of another installation identifier; it does not require authorization inside the victim organization.
Remediation and verification
Deploy commit 835e405 or a later release containing it. The corrected design verifies installation access through the GitHub user who completed authorization, persists GitHub-verified account information, removes the untrusted header fallback, and fails closed when ownership verification cannot be completed.
Integration tests should attempt to complete tenant A’s state with tenant B’s installation and assert rejection before any credential is minted or binding is persisted.
Engineering lesson
Opaque IDs are selectors, not authorization evidence. Multi-tenant OAuth and app-installation flows must cryptographically or server-side bind state, user identity, tenant scope, and the final resource identifier as one transaction.
Primary public references
- Vendor advisory GHSA-cp3j-m783-3ph5
- Affected completion flow at the audited revision
- Affected app-token sink
- Ownership-verification fix commit
- GitHub App installation-token documentation
Disclosure boundary: only already-public technical detail is included here. Unpublished validation material, private communications, secrets, and weaponized payloads remain excluded.