Skip to content

Commit fca1c81

Browse files
committed
Fix GitHub issue search repository scope
1 parent e1ab8fb commit fca1c81

10 files changed

Lines changed: 193 additions & 20 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Node test shim for github-api.ts's Workers runtime import.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
GitHubApi,
4+
type GitHubIssueResponse,
5+
} from "../src/github-api";
6+
import {
7+
assertIssueSearchResultsInRepo,
8+
buildIssueSearchQuery,
9+
} from "../src/github-search";
10+
11+
function issueAt(htmlUrl: string): Pick<GitHubIssueResponse, "html_url"> {
12+
return { html_url: htmlUrl };
13+
}
14+
15+
afterEach(() => {
16+
vi.unstubAllGlobals();
17+
});
18+
19+
describe("assertIssueSearchResultsInRepo", () => {
20+
it("accepts exact repository path segments case-insensitively", () => {
21+
expect(() => assertIssueSearchResultsInRepo("Cloudflare", "Workerd", [
22+
issueAt("https://github.com/cloudflare/workerd/issues/1"),
23+
])).not.toThrow();
24+
});
25+
26+
it("rejects results from another repository", () => {
27+
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
28+
issueAt("https://github.com/cloudflare/quiche/issues/1"),
29+
])).toThrow("outside the connected repository");
30+
});
31+
32+
it("does not accept repository names that only share a prefix", () => {
33+
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
34+
issueAt("https://github.com/cloudflare/workerd-private/issues/1"),
35+
])).toThrow("outside the connected repository");
36+
});
37+
38+
it("rejects pull requests returned by an injected search expression", () => {
39+
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
40+
issueAt("https://github.com/cloudflare/workerd/pull/1"),
41+
])).toThrow("non-issue result");
42+
});
43+
44+
it("rejects malformed and non-GitHub result URLs", () => {
45+
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
46+
issueAt("not a URL"),
47+
])).toThrow("outside the connected repository");
48+
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
49+
issueAt("https://example.com/cloudflare/workerd/issues/1"),
50+
])).toThrow("outside the connected repository");
51+
});
52+
});
53+
54+
describe("buildIssueSearchQuery", () => {
55+
it("builds a benign literal phrase search with structured filters", () => {
56+
expect(buildIssueSearchQuery("cloudflare", "workerd", {
57+
text: "durable objects",
58+
state: "open",
59+
labels: ["bug"],
60+
author: "jasnell",
61+
})).toBe(
62+
'"durable objects" repo:cloudflare/workerd is:issue state:open label:"bug" author:"jasnell"',
63+
);
64+
});
65+
66+
it("quotes every caller-controlled query fragment", () => {
67+
expect(buildIssueSearchQuery("cloudflare", "workerd", {
68+
text: "repo:cloudflare/quiche OR scheduler",
69+
author: "jasnell OR repo:cloudflare/quiche",
70+
assignee: "octocat OR repo:cloudflare/quiche",
71+
})).toBe(
72+
'"repo:cloudflare/quiche OR scheduler" repo:cloudflare/workerd is:issue '
73+
+ 'author:"jasnell OR repo:cloudflare/quiche" assignee:"octocat OR repo:cloudflare/quiche"',
74+
);
75+
});
76+
77+
it("escapes quotes inside plain search text", () => {
78+
expect(buildIssueSearchQuery("cloudflare", "workerd", {
79+
text: 'bug" OR repo:cloudflare/quiche OR "',
80+
})).toBe('"bug\\" OR repo:cloudflare/quiche OR \\"" repo:cloudflare/workerd is:issue');
81+
});
82+
});
83+
84+
describe("GitHubApi.searchIssuesConditional", () => {
85+
it("enables GitHub advanced search parsing", async () => {
86+
let requestUrl: URL | undefined;
87+
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
88+
requestUrl = new URL(String(input));
89+
return new Response(JSON.stringify({ items: [] }), {
90+
headers: { "content-type": "application/json" },
91+
});
92+
}));
93+
94+
const api = new GitHubApi(async () => "test-token");
95+
await api.searchIssuesConditional(
96+
"repo:cloudflare/quiche OR repo:cloudflare/workerd is:issue",
97+
1,
98+
100,
99+
);
100+
101+
expect(requestUrl?.searchParams.get("advanced_search")).toBe("true");
102+
});
103+
});

packages/gatekeeper-github/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"deploy": "pnpm run build:configurator && wrangler deploy",
1010
"build": "pnpm run build:configurator && tsc",
1111
"types:check": "pnpm run build:configurator && tsc --noEmit",
12+
"test": "vitest run",
1213
"clean": "rm -rf dist src/generated"
1314
},
1415
"dependencies": {
@@ -20,6 +21,7 @@
2021
},
2122
"devDependencies": {
2223
"typescript": "^5.9.3",
24+
"vitest": "^4.1.10",
2325
"wrangler": "^4.115.0"
2426
}
2527
}

packages/gatekeeper-github/src/github-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ export class GitHubApi {
596596
"/search/issues",
597597
{
598598
q: query,
599+
advanced_search: true,
599600
page,
600601
per_page: perPage,
601602
sort,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { GitHubIssueResponse } from "./github-api";
2+
import type { GitHubIssueSearch } from "./types";
3+
4+
export function buildIssueSearchQuery(owner: string, repo: string, query: GitHubIssueSearch): string {
5+
const parts = [query.text ? JSON.stringify(query.text) : "", `repo:${owner}/${repo}`, "is:issue"];
6+
if (query.state && query.state !== "all") parts.push(`state:${query.state}`);
7+
for (const label of query.labels ?? []) {
8+
parts.push(`label:${JSON.stringify(label)}`);
9+
}
10+
if (query.author) parts.push(`author:${JSON.stringify(query.author)}`);
11+
if (query.assignee) parts.push(`assignee:${JSON.stringify(query.assignee)}`);
12+
return parts.filter(Boolean).join(" ");
13+
}
14+
15+
export function assertIssueSearchResultsInRepo(
16+
owner: string,
17+
repo: string,
18+
results: readonly Pick<GitHubIssueResponse, "html_url">[],
19+
): void {
20+
const expectedOwner = owner.toLowerCase();
21+
const expectedRepo = repo.toLowerCase();
22+
23+
for (const result of results) {
24+
let url: URL | undefined;
25+
try {
26+
url = new URL(result.html_url);
27+
} catch {
28+
// Handled by the scope check below.
29+
}
30+
31+
const [resultOwner, resultRepo, resultKind] = url?.pathname.split("/").filter(Boolean) ?? [];
32+
if (url?.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com"
33+
|| resultOwner?.toLowerCase() !== expectedOwner || resultRepo?.toLowerCase() !== expectedRepo) {
34+
throw new Error("GitHub returned an issue outside the connected repository.");
35+
}
36+
if (resultKind !== "issues") {
37+
throw new Error("GitHub returned a non-issue result for an issue search.");
38+
}
39+
}
40+
}

packages/gatekeeper-github/src/github.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
type GitHubPullRequestResponse,
3030
type GitHubPullRequestReviewCommentResponse,
3131
} from "./github-api";
32+
import { assertIssueSearchResultsInRepo, buildIssueSearchQuery } from "./github-search";
3233
import GITHUB_LOGO_SVG from "./github-logo.svg";
3334
import type {
3435
GitHubActor,
@@ -110,6 +111,11 @@ type Cached<T> = {
110111
generation: number;
111112
};
112113

114+
type CachedIssueSearchResult = {
115+
html_url: string;
116+
summary: GitHubIssueSummary;
117+
};
118+
113119
type GitHubDiscussionCommentEntry = Extract<GitHubDiscussionEntry, { kind: "comment" }>;
114120

115121
type StoredCommentCacheState = {
@@ -696,17 +702,6 @@ function pullComparator(
696702
};
697703
}
698704

699-
function buildIssueSearchQuery(owner: string, repo: string, query: GitHubIssueSearch): string {
700-
const parts = [query.text, `repo:${owner}/${repo}`, "is:issue"];
701-
if (query.state && query.state !== "all") parts.push(`state:${query.state}`);
702-
for (const label of query.labels ?? []) {
703-
parts.push(`label:${JSON.stringify(label)}`);
704-
}
705-
if (query.author) parts.push(`author:${query.author}`);
706-
if (query.assignee) parts.push(`assignee:${query.assignee}`);
707-
return parts.filter(Boolean).join(" ");
708-
}
709-
710705
function parseDiffSide(side?: "LEFT" | "RIGHT" | null): "old" | "new" {
711706
return side === "LEFT" ? "old" : "new";
712707
}
@@ -2562,28 +2557,42 @@ export class GitHubGatekeeperImpl extends DurableObject<Env, GitHubGatekeeperImp
25622557
const owner = this.ctx.props.owner;
25632558
const repo = this.ctx.props.repo;
25642559
const searchQuery = buildIssueSearchQuery(owner, repo, query);
2560+
const assertSearchScope = (results: readonly Pick<GitHubIssueResponse, "html_url">[]) => {
2561+
try {
2562+
assertIssueSearchResultsInRepo(owner, repo, results);
2563+
} catch (error) {
2564+
logger.warn("GitHub issue search scope validation failed", {
2565+
event: "issue.search.scope.validation.failed", error,
2566+
});
2567+
throw error;
2568+
}
2569+
};
25652570
return new StreamingCursor<GitHubIssueSummary>({
25662571
fetchPage: async (page, perPage) => {
2567-
const cacheKey = this.#cacheKey("search-issues", stableKey(query), `p${page}`);
2568-
return await this.#loadCachedWithEtag<GitHubIssueSummary[]>(cacheKey, LIST_CACHE_TTL_MS, async etag => {
2572+
const cacheKey = this.#cacheKey("search-issues-scoped-v1", stableKey(query), `p${page}`);
2573+
const results = await this.#loadCachedWithEtag<CachedIssueSearchResult[]>(cacheKey, LIST_CACHE_TTL_MS, async etag => {
25692574
const raw = await this.#withApi(api =>
25702575
api.searchIssuesConditional(searchQuery, page, perPage, remoteSort, remoteDirection, { ifNoneMatch: etag })
25712576
);
25722577
if (raw.status === 304) {
25732578
return raw;
25742579
}
25752580

2581+
assertSearchScope(raw.data.items);
25762582
return {
25772583
status: 200,
25782584
headers: raw.headers,
2579-
data: raw.data.items
2580-
.filter(item => !item.pull_request)
2581-
.map(item => normalizeIssueSummary(owner, repo, item)),
2585+
data: raw.data.items.map(item => ({
2586+
html_url: item.html_url,
2587+
summary: normalizeIssueSummary(owner, repo, item),
2588+
})),
25822589
};
25832590
});
2591+
assertSearchScope(results);
2592+
return results.map(item => item.summary);
25842593
},
25852594
overlay: item => this.#overlayIssueLike(item, "issue", item.id),
2586-
filter: () => true, // Remote search results are already filtered by GitHub's search API.
2595+
filter: () => true, // Search scope was validated before results entered the cursor.
25872596
comparator: compare,
25882597
injectedItems: provisionals,
25892598
pageSize,

packages/gatekeeper-github/src/types.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ export interface GitHubRepo {
5353
/**
5454
* Searches issues in this repository.
5555
*
56-
* The search is limited to this repository. `query.text` is a plain-text search
57-
* string; the remaining fields are structured filters.
56+
* The search is limited to this repository. `query.text` is matched as a literal phrase;
57+
* search qualifiers in it are not interpreted. The remaining fields are structured filters.
5858
*/
5959
searchIssues(query: GitHubIssueSearch): Promise<Cursor<GitHubIssueSummary>>;
6060

packages/gatekeeper-github/storage-schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Implemented TTL cache families:
125125
- `cache:issue:<realId>` -> `GitHubIssueDetails`
126126
- `cache:pull:<realId>` -> `GitHubPullRequestDetails`
127127
- `cache:list-issues:<encodedQuery>` -> `GitHubIssueSummary[]`
128-
- `cache:search-issues:<encodedQuery>` -> `GitHubIssueSummary[]`
128+
- `cache:search-issues-scoped-v1:<encodedQuery>` -> validated source URLs and `GitHubIssueSummary` values
129129
- `cache:list-pulls:<encodedQuery>` -> `GitHubPullRequestSummary[]`
130130
- `cache:search-pulls:<encodedQuery>` -> `GitHubPullRequestSummary[]`
131131
- `cache:discussion-reviews:<realId>:p<page>` -> `GitHubDiscussionEntry[]` review-summary pages for pull discussions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { fileURLToPath } from "node:url";
2+
import { defineConfig } from "vitest/config";
3+
4+
export default defineConfig({
5+
resolve: {
6+
alias: {
7+
"cloudflare:workers": fileURLToPath(new URL("./__tests__/cloudflare-workers.ts", import.meta.url)),
8+
},
9+
},
10+
test: {
11+
include: ["__tests__/*.test.ts"],
12+
environment: "node",
13+
},
14+
});

pnpm-lock.yaml

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

0 commit comments

Comments
 (0)