Home

boring / v2

publicBoring-Software-Inc/v2
Overview Code History Branches Pull requestsIssuesInsights Campfire Docs
main
HomeOverview Code PRsIssues

#171 [Detail Bug] GitHub org installations >5000 repos can be incorrectly claimable due to silent pagination truncation

open

Opened by detail-app[bot] · 6 days ago

detail-app[bot]opened this issueAuthor· 6 days ago

Sign in to comment.

Linked pull requests

No linked pull requests yet.

Detail Bug Report

app.detail.dev/org_0f605249-55f3-46eb-880c-20ae3e45ae99/…/bug_996ffad3-126a-4586-b7d3-e368b5261106

Introduced in #61 by @ripgrim on Oct 23, 2026

Summary

  • Context: listRepositories/userInstallationRepositories in packages/forge-github/src/client/installations.ts enumerate a GitHub App installation's repositories, and are the sole data source for the claim-verification in apps/worker/src/jobs/recover-installation.ts, whose granted-admin result is the DB-stored ownership proof that lets a user claim (link) an org installation.
  • Bug: Both list functions cap pagination at MAX_PAGES = 50 pages x PAGE_SIZE = 100 = 5000 repositories and, when total_count exceeds that, silently return a truncated list — body.total_count is already available but no shortfall check is done (no error, no log, no marker).
  • Actual vs. expected: A >5000-repo installation yields a partial installed list and a partial admin list that directly drive the adminOfEvery authorization decision; expected behavior is to detect found.length < total_count after the loop and fail loudly (or fail closed), never silently authorize against partial data.
  • Impact: A user admin of only the truncated subset of an installation's repos can be wrongly verified as admin of every repo and granted granted-admin, letting them claim/administer an org installation they do not fully control; symmetrically, a legitimate full admin can be wrongly denied.

Code with Bug

// packages/forge-github/src/client/installations.ts
const PAGE_SIZE = 100;
const MAX_PAGES = 50;

async listRepositories(installationId: string): Promise<GithubInstallationRepo[]> {
	const token = await this.tokens.getToken(installationId);
	const found: GithubInstallationRepo[] = [];
	for (let page = 1; page <= MAX_PAGES; page++) {
		// ... fetch page, push repos ...
		if (
			body.repositories.length < PAGE_SIZE ||
			found.length >= body.total_count
		) {
			break;
		}
		// <-- BUG 🔴 when total_count > 5000 the loop exhausts MAX_PAGES,
		// falls out and silently returns a truncated `found`
	}
	return found;
}
// apps/worker/src/jobs/recover-installation.ts
function adminOfEvery(
	installed: GithubInstallationRepo[],
	visible: UserInstallationRepo[],
): boolean {
	const admin = new Set(
		visible.filter((repo) => repo.admin).map((repo) => repo.externalId),
	);
	return installed.every((repo) => admin.has(repo.externalId));
}
// ...
return adminOfEvery(repositories, access.repositories) ? GRANTED : NOT_ADMIN;
// <-- BUG 🔴 `repositories` is possibly truncated; hidden repos are never checked

Explanation

listRepositories and userInstallationRepositories stop after 50 pages regardless of body.total_count. When total_count exceeds 5000, the loop simply ends and returns whatever was accumulated, with no indication it is incomplete.

recover-installation uses these lists as the sole input to adminOfEvery. If the “installed repos” list is truncated, installed.every(...) can return true even when the user is not admin on repos that were omitted; if the “visible/admin” list is truncated, a true admin can be rejected.

Codebase Inconsistency

packages/forge-github/src/client/reads.ts documents its pagination ceiling and notes that "evidence records the truncation", while installations.ts returns partial data silently even though its output feeds an authorization decision (granted-admin).

Failing Test

packages/forge-github/src/client/installations-pagination.test.ts (run and passing) demonstrates the issue with total_count: 6000:

  • both list functions return exactly 5000 repos without throwing
  • adminOfEvery(truncatedInstalled, truncatedVisible) returns true when the user is only admin of repos 1..5000 (would grant granted-admin incorrectly)
  • adminOfEvery returns false when a real admin’s user list is truncated (would deny incorrectly)

Exploit Scenario

  1. An organization with >5000 repositories installs the GitHub App with "all repositories" access.
  2. An attacker is an org member who has GitHub admin on repos that appear within the first 5000 returned by /installation/repositories, but not on repos beyond that cutoff.
  3. The attacker triggers the recover flow.
  4. listRepositories silently returns only 5000 repos; adminOfEvery returns true based on incomplete installed repo data; verify() persists granted-admin.
  5. Within the TTL window, the attacker claims/links the org installation based on the persisted granted-admin proof.

Recommended Fix

After each pagination loop, compare fetched count to body.total_count and fail closed if found.length < total_count (throw or mark as truncated and ensure verify() refuses to authorize on truncated results). Apply the same to userInstallationRepositories, and add a test covering total_count > MAX_PAGES * PAGE_SIZE asserting truncation is detected.

History

This bug was introduced in commit 2e7c30f7 ("fix: only github admins can claim an installation"). The silent pagination cap pre-existed in 385e5989, but 2e7c30f7 made the truncated list part of the authorization path by adding adminOfEvery and introducing userInstallationRepositories with the same cap and no shortfall check.