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
18 changes: 15 additions & 3 deletions packages/realm-server/handlers/handle-webhook-receiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ export default function handleWebhookReceiverRequest({
// Delegate filter matching to the handler
if (
commandFilter &&
!filterHandler.matches(payload, ctxt.req.headers, commandFilter)
!(await filterHandler.matches(
payload,
Comment thread
richardhjtan marked this conversation as resolved.
ctxt.req.headers,
commandFilter,
dbAdapter,
))
) {
continue;
}
Expand All @@ -121,11 +126,18 @@ export default function handleWebhookReceiverRequest({
let realmURL: string;
let commandInput: Record<string, any>;
try {
realmURL = filterHandler.getRealmURL(commandFilter ?? {}, commandURL);
commandInput = filterHandler.buildCommandInput(
realmURL = await filterHandler.getRealmURL(
commandFilter ?? {},
commandURL,
payload,
ctxt.req.headers,
dbAdapter,
);
commandInput = await filterHandler.buildCommandInput(
payload,
ctxt.req.headers,
commandFilter ?? {},
dbAdapter,
);
} catch (error) {
console.error(
Expand Down
217 changes: 204 additions & 13 deletions packages/realm-server/handlers/webhook-filter-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,230 @@
import type Koa from 'koa';
import type { DBAdapter } from '@cardstack/runtime-common';
import { param, query } from '@cardstack/runtime-common';

export interface WebhookFilterHandler {
/** Return true if this payload matches the filter configuration. */
matches(
payload: Record<string, any>,
headers: Koa.Context['req']['headers'],
filter: Record<string, any>,
): boolean;
dbAdapter?: DBAdapter,
): Promise<boolean>;

/** Assemble the command input from the webhook payload and filter config. */
buildCommandInput(
payload: Record<string, any>,
headers: Koa.Context['req']['headers'],
filter: Record<string, any>,
): Record<string, any>;
dbAdapter?: DBAdapter,
): Promise<Record<string, any>>;

/** Determine the realm URL where the command should run. */
getRealmURL(filter: Record<string, any>, commandURL: string): string;
getRealmURL(
filter: Record<string, any>,
commandURL: string,
payload?: Record<string, any>,
headers?: Koa.Context['req']['headers'],
dbAdapter?: DBAdapter,
): Promise<string>;
}

/**
* Extract the realm URL from a Submission Card URL found in a PR body.
*
* The PR body contains a line like:
* - Submission Card: [https://app.boxel.ai/user/realm/SubmissionCard/uuid](...)
*
* The realm is everything before "SubmissionCard/" in that URL.
*/
export function extractRealmFromPrBody(
prBody: string | undefined | null,
): string | null {
if (!prBody) return null;

// Match the Submission Card URL in the PR body markdown link
let match = prBody.match(/- Submission Card: \[([^\]]+)\]/);
if (!match) return null;

let submissionCardUrl = match[1];
let submissionCardIndex = submissionCardUrl.indexOf('SubmissionCard/');
if (submissionCardIndex === -1) return null;

return submissionCardUrl.slice(0, submissionCardIndex);
Comment thread
richardhjtan marked this conversation as resolved.
}

/**
* Extract the PR number from a GitHub webhook payload.
* Different event types store the PR number in different locations.
*/
export function extractPrNumberFromPayload(
payload: Record<string, any>,
): number | null {
// pull_request, pull_request_review, pull_request_review_comment events
if (payload.pull_request?.number != null) {
return payload.pull_request.number;
}
// check_run events
if (payload.check_run?.pull_requests?.[0]?.number != null) {
return payload.check_run.pull_requests[0].number;
}
// check_suite events
if (payload.check_suite?.pull_requests?.[0]?.number != null) {
return payload.check_suite.pull_requests[0].number;
}
return null;
}

/**
* Extract the PR body from a GitHub webhook payload.
* Available on pull_request, pull_request_review, and pull_request_review_comment events.
*/
function extractPrBodyFromPayload(payload: Record<string, any>): string | null {
return (payload.pull_request?.body as string) ?? null;
}

/**
* Look up the realm URL for a PrCard with the given PR number by querying
* the card index database.
*
* The query restricts results to PrCard instances (URL contains '/PrCard/')
* to avoid matching GithubEventCard instances which also carry a prNumber
* field but may exist in a different realm.
*/
async function lookupRealmByPrNumber(
dbAdapter: DBAdapter,
prNumber: number,
): Promise<string | null> {
try {
let rows = await query(dbAdapter, [
`SELECT realm_url FROM boxel_index`,
`WHERE type = 'instance'`,
`AND (is_deleted = FALSE OR is_deleted IS NULL)`,
`AND url LIKE '%/PrCard/%'`,
`AND search_doc->>'prNumber' =`,
param(String(prNumber)),
Comment thread
richardhjtan marked this conversation as resolved.
`ORDER BY indexed_at DESC`,
`LIMIT 1`,
]);
if (rows.length > 0) {
return rows[0].realm_url as string;
}
} catch (error) {
console.warn(`Failed to look up realm for PR #${prNumber}:`, error);
}
return null;
}

/**
* Resolve the origin of the realm that a GitHub webhook event belongs to.
*
* Strategy:
* 1. If the payload contains a PR body, extract the origin from the Submission Card URL
* 2. Otherwise, look up the PrCard by prNumber in the index DB and extract its origin
*
* Returns null if the origin cannot be determined.
*/
async function resolveOriginFromPayload(
payload: Record<string, any>,
dbAdapter?: DBAdapter,
): Promise<string | null> {
// Strategy 1: Extract from PR body (available on pull_request, pull_request_review events)
let prBody = extractPrBodyFromPayload(payload);
let realm = extractRealmFromPrBody(prBody);
if (realm) {
try {
return new URL(realm).origin;
} catch {
// fall through
}
}

// Strategy 2: Look up PrCard by prNumber (for check_run, check_suite events)
if (dbAdapter) {
let prNumber = extractPrNumberFromPayload(payload);
if (prNumber != null) {
let realmUrl = await lookupRealmByPrNumber(dbAdapter, prNumber);
if (realmUrl) {
try {
return new URL(realmUrl).origin;
} catch {
// fall through
}
}
}
}

return null;
}

/**
* Handler for GitHub webhook events. Supports filtering by event type
* (from X-GitHub-Event header).
* (from X-GitHub-Event header) and dynamically resolves the target realm
* from the PR body's Submission Card URL or by looking up the PrCard.
*/
class GithubEventFilterHandler implements WebhookFilterHandler {
matches(
_payload: Record<string, any>,
async matches(
payload: Record<string, any>,
headers: Koa.Context['req']['headers'],
filter: Record<string, any>,
): boolean {
dbAdapter?: DBAdapter,
): Promise<boolean> {
let eventType = headers['x-github-event'] as string | undefined;

if (filter.eventType && filter.eventType !== eventType) {
return false;
}

// If the filter has a configured realm, check that the event's realm
// belongs to the same server/origin. This ensures each environment
// only processes events from PRs that originated in that environment.
//
// First try the cheap path: extract realm from the PR body (no DB query).
// If that's not available, fall back to the full resolution strategy
// (which may query the DB for check_run/check_suite events).
if (filter.realm) {
let resolvedOrigin = await resolveOriginFromPayload(payload, dbAdapter);
Comment on lines +185 to +186
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit origin checks to PR-linked events

matches() now calls resolveOriginFromPayload() for every github-event command that has filter.realm, regardless of event type. We still register commit_comment and discussion_comment commands with a realm in packages/matrix/scripts/register-github-webhook-for-submission-realm.ts (lines 235-247), but those payloads do not carry PR body/PR number fields, so origin resolution returns null and this branch rejects them every time. This regresses existing behavior by making those configured webhook commands impossible to execute.

Useful? React with 👍 / 👎.


if (resolvedOrigin) {
try {
let filterOrigin = new URL(filter.realm as string).origin;
if (filterOrigin !== resolvedOrigin) {
return false;
}
} catch {
// filter.realm is malformed — reject to avoid bypassing origin check
console.warn(
`Failed to parse filter.realm URL (${filter.realm}), rejecting match`,
);
return false;
}
} else {
// Could not resolve origin from payload — reject the match to prevent
// cross-environment broadcast. This is the safer default: if we can't
// determine which environment the PR belongs to, don't process it.
let prNumber = extractPrNumberFromPayload(payload);
console.warn(
Comment thread
richardhjtan marked this conversation as resolved.
`Could not resolve realm origin from webhook payload ` +
`(eventType=${eventType}, prNumber=${prNumber ?? 'unknown'}), ` +
`rejecting match`,
);
return false;
}
}

return true;
Comment thread
richardhjtan marked this conversation as resolved.
}

buildCommandInput(
async buildCommandInput(
payload: Record<string, any>,
headers: Koa.Context['req']['headers'],
filter: Record<string, any>,
): Record<string, any> {
): Promise<Record<string, any>> {
let eventType = (headers['x-github-event'] as string) ?? '';

// Always use the static filter.realm (the submissions realm) for the
// command input. The dynamic origin check in matches() already ensures
// we only reach here for the correct environment.
let realm = filter.realm as string | undefined;
if (!realm) {
throw new Error(
Expand All @@ -58,7 +239,12 @@ class GithubEventFilterHandler implements WebhookFilterHandler {
};
}

getRealmURL(filter: Record<string, any>, commandURL: string): string {
async getRealmURL(
filter: Record<string, any>,
commandURL: string,
): Promise<string> {
// Always use the static filter.realm. The dynamic origin check in
// matches() already ensures we only reach here for the correct environment.
return (
(filter.realm as string | undefined) ??
new URL('/submissions/', commandURL).href
Expand All @@ -72,15 +258,20 @@ class GithubEventFilterHandler implements WebhookFilterHandler {
* command input.
*/
class DefaultFilterHandler implements WebhookFilterHandler {
matches(): boolean {
async matches(): Promise<boolean> {
return true;
}

buildCommandInput(payload: Record<string, any>): Record<string, any> {
async buildCommandInput(
payload: Record<string, any>,
): Promise<Record<string, any>> {
return { payload };
}

getRealmURL(filter: Record<string, any>, commandURL: string): string {
async getRealmURL(
filter: Record<string, any>,
commandURL: string,
): Promise<string> {
return (
(filter.realmUrl as string | undefined) ?? new URL('/', commandURL).href
);
Expand Down
Loading
Loading