Skip to content

Commit e2ff49c

Browse files
authored
[Improve] Create public GitHub Apps so the install step picks the org (#416)
1 parent ea8de6e commit e2ff49c

10 files changed

Lines changed: 392 additions & 39 deletions

File tree

apps/api/src/handlers/github/__tests__/index.test.ts

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts

Lines changed: 128 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/api/src/handlers/github/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { handlePushConflictCheck } from './handlePushConflictCheck';
3535
import { handleWorkflowRunCompleted } from './handleWorkflowRunCompleted';
3636

3737
// Utilities:
38+
import { isFromKnownInstallation } from './isFromKnownInstallation';
3839
import { recordWebhook } from './recordWebhook';
3940

4041
/**
@@ -487,6 +488,23 @@ github.post('/', async (c) => {
487488

488489
const payload = await c.req.text();
489490

491+
// Verify the signature before the installation lookup so unsigned junk
492+
// cannot trigger database reads.
493+
if (!(await webhooks.verify(payload, signature))) {
494+
return c.json({ error: 'invalid_signature' }, { status: 401 });
495+
}
496+
497+
// The app is public, so any account can install it and produce validly
498+
// signed deliveries; drop events from installations this deployment does
499+
// not know before they are recorded or handled.
500+
if (!(await isFromKnownInstallation(name, payload))) {
501+
apiLogger.debug(
502+
`[GitHub] ignoring webhook ${id} (${name}) from unknown installation`,
503+
);
504+
505+
return c.json({ message: 'unknown_installation' });
506+
}
507+
490508
// The event handlers classify logins synchronously (mention detection,
491509
// bot-identity checks); refresh the configured app slug first so an app
492510
// created through the /setup flow is recognized as ourselves.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
db,
3+
eq,
4+
githubInstallations,
5+
githubPendingInstallations,
6+
} from '@roomote/db/server';
7+
8+
/**
9+
* A public GitHub App can be installed by any account, so a validly signed
10+
* webhook delivery is not proof of a relationship with this deployment.
11+
* Events must reference an installation this deployment has already synced
12+
* before they are recorded or handled. `installation` creation events are
13+
* instead matched against pending installations - the same check their
14+
* handler enforces - so installs nobody requested through this deployment
15+
* are dropped before they are persisted.
16+
*/
17+
export async function isFromKnownInstallation(
18+
eventName: string,
19+
rawPayload: string,
20+
): Promise<boolean> {
21+
let action: string | undefined;
22+
let installationId: number | undefined;
23+
let accountId: number | undefined;
24+
25+
try {
26+
const parsed: unknown = JSON.parse(rawPayload);
27+
28+
if (typeof parsed !== 'object' || parsed === null) {
29+
return true;
30+
}
31+
32+
const payload = parsed as {
33+
action?: unknown;
34+
installation?: { id?: unknown; account?: { id?: unknown } | null } | null;
35+
};
36+
37+
action = typeof payload.action === 'string' ? payload.action : undefined;
38+
installationId =
39+
typeof payload.installation?.id === 'number'
40+
? payload.installation.id
41+
: undefined;
42+
accountId =
43+
typeof payload.installation?.account?.id === 'number'
44+
? payload.installation.account.id
45+
: undefined;
46+
} catch {
47+
// Defer malformed payloads to verifyAndReceive's own error handling.
48+
return true;
49+
}
50+
51+
if (eventName === 'installation' && action === 'created') {
52+
if (accountId === undefined) {
53+
return false;
54+
}
55+
56+
// The pending row's appId column stores the account id of the requested
57+
// installation target; see finishCreateGitHubInstallationCommand.
58+
const pendingInstallation =
59+
await db.query.githubPendingInstallations.findFirst({
60+
where: eq(githubPendingInstallations.appId, accountId),
61+
columns: { id: true },
62+
});
63+
64+
return pendingInstallation !== undefined;
65+
}
66+
67+
if (installationId === undefined) {
68+
// Signed events without an installation reference (for example `ping`)
69+
// come from GitHub for this app itself.
70+
return true;
71+
}
72+
73+
const installation = await db.query.githubInstallations.findFirst({
74+
where: eq(githubInstallations.installationId, installationId),
75+
columns: { id: true },
76+
});
77+
78+
return installation !== undefined;
79+
}

apps/docs/providers/source-control/github.mdx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ Open `/setup`, choose GitHub, and click **Create GitHub App**. Roomote sends
1616
GitHub a manifest with the callback, webhook, permissions, and events
1717
preconfigured. After GitHub redirects back, Roomote saves the generated app ID,
1818
app slug, OAuth client credentials, webhook secret, and private key, then sends
19-
you to install the app on repositories.
19+
you to install the app on repositories. You pick the account or organization
20+
to install on during that install step.
21+
22+
By default the app is created on your personal GitHub account and marked as
23+
installable on any account, so you can install it on any organization you
24+
belong to. If your organization should own the app instead, click **Show
25+
advanced config** and enter the organization name before clicking **Create
26+
GitHub App**.
2027

2128
Use the manual steps only when you already have a GitHub App or want to manage
2229
these values in deployment environment variables yourself.
@@ -55,6 +62,11 @@ these values in deployment environment variables yourself.
5562
openssl rand -hex 32
5663
```
5764

65+
11. Under **Where can this GitHub App be installed?**, choose **Any account**
66+
if you want to install the app somewhere other than the account that owns
67+
it. **Only on this account** also works when the owning account is where
68+
you will install it.
69+
5870
## Permissions and events
5971

6072
Grant these repository permissions:

0 commit comments

Comments
 (0)