CVE case study

CVE-2026-59185: Missing Installation Ownership Check Allowed Cross-Tenant Access

The backend accepted a GitHub App installation ID without verifying that the authorizing GitHub user could access that installation.

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 found it

I searched the GitHub App completion flow for request-controlled installation IDs and followed each one to the app-token endpoint.

identrail bound state to the caller’s tenant, workspace, and project. The installation ID beside it received only a positive-number check before persistence, and the router allowed a client-controlled header fallback.

The stored ID reached ListInstallationRepositories() and the app-JWT request to /app/installations/{id}/access_tokens. Because a shared GitHub App can mint a token for any of its installations, the missing ownership check created a cross-tenant credential context.

Root cause

State was tied to the workspace, but the installation ID was not tied to the GitHub user completing authorization. The backend then minted a token for that unchecked ID.

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.

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

A state from another workspace is rejected, while the vulnerable model accepts a foreign installation ID paired with the caller's valid state.

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

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.

Fix and retest

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

Treat installation IDs as selectors. Verify that the GitHub user can access the installation before storing it or minting a token.

References

Further reading

Evidence connected to this article.

Back to article start