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

  1. 01
    Client sourceinternal/api/router.go

    Connection completion reads installation_id from the JSON body or a client-controlled header.

  2. 02
    Correct state checkCompleteGitHubConnection()

    State is bound to tenant, workspace, and project, proving the intended scope model.

  3. 03
    Missing relationshiprequest.InstallationID validation

    The identifier is checked only for being greater than zero, not for ownership by the authorizing GitHub user or organization.

  4. 04
    Credential sinkconnectors/github/app.go

    The application JWT mints an installation token for the supplied ID and uses it to list repositories.

Cross-tenant binding pathThe vulnerability sits between a correctly scoped state token and an independently trusted resource identifier.
  1. 01
    Legitimate start

    An authenticated tenant begins a connection and receives state bound to its own workspace.

  2. 02
    Identifier substitution

    Connection completion supplies a different customer’s GitHub App installation identifier.

  3. 03
    App token minting

    The backend trusts that identifier and asks GitHub for an installation-scoped token.

  4. 04
    Persistent foreign binding

    The victim installation becomes available through the attacker’s identrail workspace.

Safe proof of concept

Prerequisites

  • Git and rg installed locally.
  • No identrail account, GitHub App credential, installation ID, or network target is required after cloning the public repository.

Step-by-step reproduction

  1. Clone the public repository and check out the affected revision from the vendor advisory.
  2. Locate the connection-completion route and verify that the identifier can originate in the request body or header.
  3. Inspect CompleteGitHubConnection(): record the rigorous state-to-scope comparison, then the separate InstallationID <= 0 check and persistence assignment.
  4. Follow the identifier into the connector and confirm the app-JWT installation-token path.
  5. Run the synthetic model below. It proves the authorization invariant with two invented tenants and performs no HTTP request.
  6. 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.go

Synthetic 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.go

Expected 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.
Negative control

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.

Fixed-version re-test

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

Disclosure boundary: only already-public technical detail is included here. Unpublished validation material, private communications, secrets, and weaponized payloads remain excluded.