Opened by detail-app[bot] ยท 6 days ago
Sign in to comment.
app.detail.dev/org_0f605249-55f3-46eb-880c-20ae3e45ae99/โฆ/bug_1efeb345-792f-4ce9-8840-c4377d72f3a2
Introduced in #55 by @ripgrim on Oct 22, 2026
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.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.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.installation_access_checks table (nothing ever prunes it), and flood Sentry with per-job failure warnings.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);
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.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.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.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.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.
installationId format (GitHub installation ids are numeric) and add per-user rate limiting to recoverInstallation (e.g., in accessGuardMiddleware or dedicated middleware).installation_access_checks (e.g., delete rows older than a few days based on verified_at).This bug was introduced in commit 385e5989 (feat: recover github installs when the webhook is late or lost).