Skip to content

Commit 3cbbaed

Browse files
authored
fix(webapp): scope GitHub App installation update to the caller's org (#58)
* fix(webapp): scope GitHub App installation update to the caller's org * fix(webapp): make GitHub App install session single-use Invalidate the GitHub App installation state cookie once a callback consumes an installation_id, so a single install initiation cannot be replayed against other installation_ids.
1 parent d212155 commit 3cbbaed

3 files changed

Lines changed: 48 additions & 14 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Updating a GitHub App installation from the callback flow is now scoped to your own organization, so an installation ID belonging to another organization can no longer be used to refresh that organization's installation record. The GitHub App installation session is also now single-use, so completing an installation callback invalidates its state and it can no longer be replayed.

apps/webapp/app/routes/_app.github.callback/route.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { type LoaderFunctionArgs } from "@remix-run/node";
22
import { z } from "zod";
3-
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
3+
import {
4+
destroyGitHubAppInstallSession,
5+
validateGitHubAppInstallSession,
6+
} from "~/services/gitHubSession.server";
47
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
58
import { logger } from "~/services/logger.server";
69
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
@@ -75,6 +78,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
7578
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
7679
}
7780

81+
// The install session is single-use: once a callback consumes an
82+
// installation_id, invalidate the state cookie so the same initiation
83+
// cannot be replayed against other installation_ids.
84+
const clearInstallSession = await destroyGitHubAppInstallSession(cookieHeader);
85+
const consumingSession = (response: Response) => {
86+
response.headers.append("Set-Cookie", clearInstallSession);
87+
return response;
88+
};
89+
7890
switch (callbackData.setup_action) {
7991
case "install": {
8092
const [error] = await tryCatch(
@@ -85,23 +97,33 @@ export async function loader({ request }: LoaderFunctionArgs) {
8597
logger.error("Failed to link GitHub App installation", {
8698
error,
8799
});
88-
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
100+
return consumingSession(
101+
await redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app")
102+
);
89103
}
90104

91-
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
105+
return consumingSession(
106+
await redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully")
107+
);
92108
}
93109

94110
case "update": {
95-
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));
111+
const [error] = await tryCatch(
112+
updateGitHubAppInstallation(callbackData.installation_id, organizationId)
113+
);
96114

97115
if (error) {
98116
logger.error("Failed to update GitHub App installation", {
99117
error,
100118
});
101-
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
119+
return consumingSession(
120+
await redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App")
121+
);
102122
}
103123

104-
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
124+
return consumingSession(
125+
await redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully")
126+
);
105127
}
106128

107129
case "request": {

apps/webapp/app/services/gitHub.server.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,26 +58,32 @@ export async function linkGitHubAppInstallation(
5858
}
5959

6060
/**
61-
* Links a GitHub App installation to a Trigger organization
61+
* Updates a GitHub App installation owned by the given Trigger organization
6262
*/
63-
export async function updateGitHubAppInstallation(installationId: number): Promise<void> {
63+
export async function updateGitHubAppInstallation(
64+
installationId: number,
65+
organizationId: string
66+
): Promise<void> {
6467
if (!githubApp) {
6568
throw new Error("GitHub App is not enabled");
6669
}
6770

68-
const octokit = await githubApp.getInstallationOctokit(installationId);
69-
const { data: installation } = await octokit.rest.apps.getInstallation({
70-
installation_id: installationId,
71-
});
72-
71+
// Scope the lookup to the caller's organization so a cross-tenant
72+
// installation_id cannot update another org's record. Resolve ownership
73+
// before calling GitHub to avoid burning the victim's API rate limit.
7374
const existingInstallation = await prisma.githubAppInstallation.findFirst({
74-
where: { appInstallationId: installationId },
75+
where: { appInstallationId: installationId, organizationId },
7576
});
7677

7778
if (!existingInstallation) {
7879
throw new Error("GitHub App installation not found");
7980
}
8081

82+
const octokit = await githubApp.getInstallationOctokit(installationId);
83+
const { data: installation } = await octokit.rest.apps.getInstallation({
84+
installation_id: installationId,
85+
});
86+
8187
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
8288

8389
// repos are updated asynchronously via webhook events

0 commit comments

Comments
 (0)