Home

boring / v2

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

#172 [Detail Bug] Onboarding: Recover installation endpoint allows authenticated users to enqueue unlimited GitHub API work and permanently grow access-check table

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_1efeb345-792f-4ce9-8840-c4377d72f3a2

Introduced in #55 by @ripgrim on Oct 22, 2026

Summary

  • Context: recoverInstallation is the web-app server function that lets a signed-in user recover a GitHub App installation whose installation_id was lost in the Setup callback; it persists an access-check row and enqueues a worker job that verifies the installation against GitHub.
  • Bug: The endpoint accepts an arbitrary, unvalidated installationId string from any authenticated user with no ownership/applicability check, no format validation and no rate limit, and it permanently stores a row for each distinct id it receives (repeated calls with the same id overwrite that row via the unique (user_id, forge, installation_id) constraint) and dequeues GitHub API work for it.
  • Actual vs. expected: Expected: only plausible, bounded recovery attempts (validated id, rate-limited, ideally only for applicable users). Actual: every POST unconditionally upserts an installation_access_checks row for any id the caller invents and sends a recover-installation job whose worker makes GitHub App API calls for that id.
  • Impact: Any authenticated user can, at request rate, make the worker burn the shared GitHub App API budget on invented installation ids (degrading installation sync for all tenants, risking GitHub abuse detection), permanently grow the installation_access_checks table (nothing ever prunes it), and flood Sentry with per-job failure warnings.

Code with Bug

apps/web/src/lib/onboarding.functions.ts

export const recoverInstallation = createServerFn({ method: "POST" })
	.middleware([accessGuardMiddleware])
	.inputValidator((input: { installationId: string }) => input) // <-- BUG ๐Ÿ”ด no validation at all (not even numeric format)
	.handler(async ({ data }): Promise<{ queued: boolean }> => {
		...
		const job = { userId, installationId: data.installationId };
		await installationAccessServices.recordInstallAccessCheck(getDb().db, {
			userId,
			forge: "github",
			installationId: data.installationId, // <-- BUG ๐Ÿ”ด permanent row for any invented id
			result: "pending",
		});
		const boss = await getBoss();
		await boss.send(
			RECOVER_INSTALLATION_QUEUE,
			job, // <-- BUG ๐Ÿ”ด worker makes GitHub API calls for the arbitrary id
			recoverInstallationOptions(job),
		);

packages/db/src/queue.ts

export function recoverInstallationOptions(job: RecoverInstallationJob) {
	return { singletonKey: `${job.userId}:${job.installationId}` }; // <-- BUG ๐Ÿ”ด dedup only coalesces identical in-flight jobs; distinct ids (or resends after completion) enqueue fresh work
}

apps/worker/src/jobs/recover-installation.ts

const installation = await github.getInstallation(job.installationId); // <-- BUG ๐Ÿ”ด a nonexistent/invented id still costs an app-JWT API call (404 โ†’ "not-for-tripwire")
const repositories = await github.listRepositories(job.installationId);

Explanation

  • recoverInstallation accepts arbitrary user input via a pass-through inputValidator, writes a pending record to installation_access_checks, and enqueues a recover-installation job without checking that the id is numeric/plausible, that the installation exists, or that the caller has any relationship to it.
  • Queue deduplication uses only a singletonKey of (userId, installationId), which only prevents concurrently enqueuing the same id for the same user; attackers can rotate ids (or resend after completion) to create unlimited jobs.
  • The worker consumes GitHub App API quota using the raw installationId (github.getInstallation(...) at minimum) even when the id is invented, and failures are logged.
  • installation_access_checks has no TTL cleanup: rows that no longer match freshness predicates remain indefinitely (no deletes anywhere except user deletion cascade), so distinct attacker-supplied ids permanently grow the table.

Codebase Inconsistency

  • Similar entry points are stricter: claimInstallation performs permission + ownership checks before writing, while getInstallPreview only reads from DB. recoverInstallation is the outlier that both persists attacker-keyed state and triggers third-party API work with no validation/rate limiting.

Exploit Scenario

An attacker with a normal authenticated account repeatedly POSTs recoverInstallation with a different random installationId each time. Each request creates/updates an installation_access_checks row and enqueues a recover-installation job, causing at least one GitHub App API call (getInstallation) per request, plus warning-level failure noise; over time this can exhaust shared GitHub API quota, build up queue backlog/Sentry warnings, and permanently grow installation_access_checks.

Recommended Fix

  • Validate installationId format (GitHub installation ids are numeric) and add per-user rate limiting to recoverInstallation (e.g., in accessGuardMiddleware or dedicated middleware).
  • Add scheduled TTL pruning for installation_access_checks (e.g., delete rows older than a few days based on verified_at).

History

This bug was introduced in commit 385e5989 (feat: recover github installs when the webhook is late or lost).