@@ -86,6 +86,10 @@ function extractPrBodyFromPayload(payload: Record<string, any>): string | null {
8686/**
8787 * Look up the realm URL for a PrCard with the given PR number by querying
8888 * the card index database.
89+ *
90+ * The query restricts results to PrCard instances (URL contains '/PrCard/')
91+ * to avoid matching GithubEventCard instances which also carry a prNumber
92+ * field but may exist in a different realm.
8993 */
9094async function lookupRealmByPrNumber (
9195 dbAdapter : DBAdapter ,
@@ -96,8 +100,10 @@ async function lookupRealmByPrNumber(
96100 `SELECT realm_url FROM boxel_index` ,
97101 `WHERE type = 'instance'` ,
98102 `AND is_deleted = FALSE` ,
103+ `AND url LIKE '%/PrCard/%'` ,
99104 `AND search_doc->>'prNumber' =` ,
100105 param ( String ( prNumber ) ) ,
106+ `ORDER BY indexed_at DESC` ,
101107 `LIMIT 1` ,
102108 ] ) ;
103109 if ( rows . length > 0 ) {
@@ -110,34 +116,45 @@ async function lookupRealmByPrNumber(
110116}
111117
112118/**
113- * Resolve the realm URL dynamically from the GitHub webhook payload .
119+ * Resolve the origin of the realm that a GitHub webhook event belongs to .
114120 *
115121 * Strategy:
116- * 1. If the payload contains a PR body, extract the realm from the Submission Card URL
117- * 2. Otherwise, look up the PrCard by prNumber in the index DB
118- * 3. Fall back to the static filter.realm if neither works
122+ * 1. If the payload contains a PR body, extract the origin from the Submission Card URL
123+ * 2. Otherwise, look up the PrCard by prNumber in the index DB and extract its origin
124+ *
125+ * Returns null if the origin cannot be determined.
119126 */
120- async function resolveRealmFromPayload (
127+ async function resolveOriginFromPayload (
121128 payload : Record < string , any > ,
122- filter : Record < string , any > ,
123129 dbAdapter ?: DBAdapter ,
124130) : Promise < string | null > {
125131 // Strategy 1: Extract from PR body (available on pull_request, pull_request_review events)
126132 let prBody = extractPrBodyFromPayload ( payload ) ;
127133 let realm = extractRealmFromPrBody ( prBody ) ;
128- if ( realm ) return realm ;
134+ if ( realm ) {
135+ try {
136+ return new URL ( realm ) . origin ;
137+ } catch {
138+ // fall through
139+ }
140+ }
129141
130142 // Strategy 2: Look up PrCard by prNumber (for check_run, check_suite events)
131143 if ( dbAdapter ) {
132144 let prNumber = extractPrNumberFromPayload ( payload ) ;
133145 if ( prNumber != null ) {
134- realm = await lookupRealmByPrNumber ( dbAdapter , prNumber ) ;
135- if ( realm ) return realm ;
146+ let realmUrl = await lookupRealmByPrNumber ( dbAdapter , prNumber ) ;
147+ if ( realmUrl ) {
148+ try {
149+ return new URL ( realmUrl ) . origin ;
150+ } catch {
151+ // fall through
152+ }
153+ }
136154 }
137155 }
138156
139- // Strategy 3: Fall back to static filter.realm
140- return ( filter . realm as string ) ?? null ;
157+ return null ;
141158}
142159
143160/**
@@ -166,37 +183,32 @@ class GithubEventFilterHandler implements WebhookFilterHandler {
166183 // If that's not available, fall back to the full resolution strategy
167184 // (which may query the DB for check_run/check_suite events).
168185 if ( filter . realm ) {
169- let prBody = extractPrBodyFromPayload ( payload ) ;
170- let resolvedRealm = extractRealmFromPrBody ( prBody ) ;
186+ let resolvedOrigin = await resolveOriginFromPayload ( payload , dbAdapter ) ;
171187
172- if ( ! resolvedRealm && dbAdapter ) {
173- let prNumber = extractPrNumberFromPayload ( payload ) ;
174- if ( prNumber != null ) {
175- resolvedRealm = await lookupRealmByPrNumber ( dbAdapter , prNumber ) ;
176- }
177- }
178-
179- if ( resolvedRealm ) {
188+ if ( resolvedOrigin ) {
180189 try {
181190 let filterOrigin = new URL ( filter . realm as string ) . origin ;
182- let resolvedOrigin = new URL ( resolvedRealm ) . origin ;
183191 if ( filterOrigin !== resolvedOrigin ) {
184192 return false ;
185193 }
186194 } catch {
195+ // filter.realm is malformed — reject to avoid bypassing origin check
187196 console . warn (
188- `Failed to compare realm origins for webhook filter ` +
189- `(filter.realm=${ filter . realm } , resolvedRealm=${ resolvedRealm } ), ` +
190- `allowing match as fallback` ,
197+ `Failed to parse filter.realm URL (${ filter . realm } ), rejecting match` ,
191198 ) ;
199+ return false ;
192200 }
193201 } else {
202+ // Could not resolve origin from payload — reject the match to prevent
203+ // cross-environment broadcast. This is the safer default: if we can't
204+ // determine which environment the PR belongs to, don't process it.
194205 let prNumber = extractPrNumberFromPayload ( payload ) ;
195206 console . warn (
196- `Could not resolve realm from webhook payload ` +
197- `(eventType=${ eventType } , prNumber=${ prNumber ?? 'unknown' } ). ` +
198- `Falling back to broadcast — event will be processed by all environments. ` ,
207+ `Could not resolve realm origin from webhook payload ` +
208+ `(eventType=${ eventType } , prNumber=${ prNumber ?? 'unknown' } ), ` +
209+ `rejecting match ` ,
199210 ) ;
211+ return false ;
200212 }
201213 }
202214
@@ -207,16 +219,16 @@ class GithubEventFilterHandler implements WebhookFilterHandler {
207219 payload : Record < string , any > ,
208220 headers : Koa . Context [ 'req' ] [ 'headers' ] ,
209221 filter : Record < string , any > ,
210- dbAdapter ?: DBAdapter ,
211222 ) : Promise < Record < string , any > > {
212223 let eventType = ( headers [ 'x-github-event' ] as string ) ?? '' ;
213224
214- let realm = await resolveRealmFromPayload ( payload , filter , dbAdapter ) ;
225+ // Always use the static filter.realm (the submissions realm) for the
226+ // command input. The dynamic origin check in matches() already ensures
227+ // we only reach here for the correct environment.
228+ let realm = filter . realm as string | undefined ;
215229 if ( ! realm ) {
216230 throw new Error (
217- 'Could not determine realm for github-event webhook command: ' +
218- 'no Submission Card URL found in PR body, no PrCard found in index, ' +
219- 'and no static realm configured in filter' ,
231+ 'realm must be provided in the filter for github-event webhook commands' ,
220232 ) ;
221233 }
222234
@@ -230,14 +242,9 @@ class GithubEventFilterHandler implements WebhookFilterHandler {
230242 async getRealmURL (
231243 filter : Record < string , any > ,
232244 commandURL : string ,
233- payload ?: Record < string , any > ,
234- _headers ?: Koa . Context [ 'req' ] [ 'headers' ] ,
235- dbAdapter ?: DBAdapter ,
236245 ) : Promise < string > {
237- if ( payload ) {
238- let realm = await resolveRealmFromPayload ( payload , filter , dbAdapter ) ;
239- if ( realm ) return realm ;
240- }
246+ // Always use the static filter.realm. The dynamic origin check in
247+ // matches() already ensures we only reach here for the correct environment.
241248 return (
242249 ( filter . realm as string | undefined ) ??
243250 new URL ( '/submissions/' , commandURL ) . href
0 commit comments