Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/api/src/handlers/github/__tests__/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions apps/api/src/handlers/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { handlePushConflictCheck } from './handlePushConflictCheck';
import { handleWorkflowRunCompleted } from './handleWorkflowRunCompleted';

// Utilities:
import { isFromKnownInstallation } from './isFromKnownInstallation';
import { recordWebhook } from './recordWebhook';

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

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

// Verify the signature before the installation lookup so unsigned junk
// cannot trigger database reads.
if (!(await webhooks.verify(payload, signature))) {
return c.json({ error: 'invalid_signature' }, { status: 401 });
}

// The app is public, so any account can install it and produce validly
// signed deliveries; drop events from installations this deployment does
// not know before they are recorded or handled.
if (!(await isFromKnownInstallation(name, payload))) {
apiLogger.debug(
`[GitHub] ignoring webhook ${id} (${name}) from unknown installation`,
);

return c.json({ message: 'unknown_installation' });
}

// The event handlers classify logins synchronously (mention detection,
// bot-identity checks); refresh the configured app slug first so an app
// created through the /setup flow is recognized as ourselves.
Expand Down
79 changes: 79 additions & 0 deletions apps/api/src/handlers/github/isFromKnownInstallation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
db,
eq,
githubInstallations,
githubPendingInstallations,
} from '@roomote/db/server';

/**
* A public GitHub App can be installed by any account, so a validly signed
* webhook delivery is not proof of a relationship with this deployment.
* Events must reference an installation this deployment has already synced
* before they are recorded or handled. `installation` creation events are
* instead matched against pending installations - the same check their
* handler enforces - so installs nobody requested through this deployment
* are dropped before they are persisted.
*/
export async function isFromKnownInstallation(
eventName: string,
rawPayload: string,
): Promise<boolean> {
let action: string | undefined;
let installationId: number | undefined;
let accountId: number | undefined;

try {
const parsed: unknown = JSON.parse(rawPayload);

if (typeof parsed !== 'object' || parsed === null) {
return true;
}

const payload = parsed as {
action?: unknown;
installation?: { id?: unknown; account?: { id?: unknown } | null } | null;
};

action = typeof payload.action === 'string' ? payload.action : undefined;
installationId =
typeof payload.installation?.id === 'number'
? payload.installation.id
: undefined;
accountId =
typeof payload.installation?.account?.id === 'number'
? payload.installation.account.id
: undefined;
} catch {
// Defer malformed payloads to verifyAndReceive's own error handling.
return true;
}

if (eventName === 'installation' && action === 'created') {
if (accountId === undefined) {
return false;
}

// The pending row's appId column stores the account id of the requested
// installation target; see finishCreateGitHubInstallationCommand.
const pendingInstallation =
await db.query.githubPendingInstallations.findFirst({
where: eq(githubPendingInstallations.appId, accountId),
columns: { id: true },
});

return pendingInstallation !== undefined;
}

if (installationId === undefined) {
// Signed events without an installation reference (for example `ping`)
// come from GitHub for this app itself.
return true;
}

const installation = await db.query.githubInstallations.findFirst({
where: eq(githubInstallations.installationId, installationId),
columns: { id: true },
});

return installation !== undefined;
}
14 changes: 13 additions & 1 deletion apps/docs/providers/source-control/github.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ Open `/setup`, choose GitHub, and click **Create GitHub App**. Roomote sends
GitHub a manifest with the callback, webhook, permissions, and events
preconfigured. After GitHub redirects back, Roomote saves the generated app ID,
app slug, OAuth client credentials, webhook secret, and private key, then sends
you to install the app on repositories.
you to install the app on repositories. You pick the account or organization
to install on during that install step.

By default the app is created on your personal GitHub account and marked as
installable on any account, so you can install it on any organization you
belong to. If your organization should own the app instead, click **Show
advanced config** and enter the organization name before clicking **Create
GitHub App**.

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

11. Under **Where can this GitHub App be installed?**, choose **Any account**
if you want to install the app somewhere other than the account that owns
it. **Only on this account** also works when the owning account is where
you will install it.

## Permissions and events

Grant these repository permissions:
Expand Down
Loading
Loading