Skip to content

Commit 8191a69

Browse files
committed
fix(miner): restrict check poller API origin
1 parent eb97940 commit 8191a69

2 files changed

Lines changed: 54 additions & 5 deletions

File tree

packages/gittensory-miner/lib/ci-poller.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,32 @@ const defaultMaxIntervalMs = 5 * 60_000;
44
const defaultMaxAttempts = 1;
55
const githubApiVersion = "2022-11-28";
66

7+
function normalizeApiBaseUrl(value) {
8+
if (value === undefined) return defaultApiBaseUrl;
9+
if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl;
10+
let parsed;
11+
try {
12+
parsed = new URL(value.trim());
13+
} catch {
14+
throw new Error("invalid_api_base_url");
15+
}
16+
if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") {
17+
throw new Error("invalid_api_base_url");
18+
}
19+
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
20+
parsed.search = "";
21+
parsed.hash = "";
22+
return parsed.toString().replace(/\/+$/, "");
23+
}
24+
725
function normalizePositiveInt(value, fallback, min, max) {
826
if (!Number.isFinite(value)) return fallback;
927
return Math.min(max, Math.max(min, Math.floor(value)));
1028
}
1129

1230
function normalizeOptions(options = {}) {
1331
return {
14-
apiBaseUrl:
15-
typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim()
16-
? options.apiBaseUrl.trim().replace(/\/+$/, "")
17-
: defaultApiBaseUrl,
32+
apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl),
1833
fetchFn: options.fetchFn ?? fetch,
1934
githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "",
2035
maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20),

test/unit/miner-ci-poller.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { pollCheckRuns } from "../../packages/gittensory-miner/lib/ci-poller.js";
33

4-
const API = "https://api.test";
4+
const API = "https://api.github.com";
55

66
function jsonResponse(body: unknown, init: ResponseInit = {}) {
77
return Response.json(body, init);
@@ -68,6 +68,40 @@ describe("miner CI check-run poller (#2323)", () => {
6868
).toBe(true);
6969
});
7070

71+
it("rejects untrusted apiBaseUrl values before any token-bearing request", async () => {
72+
const fetchFn = vi.fn();
73+
for (const apiBaseUrl of [
74+
"http://api.github.com",
75+
"https://evil.example",
76+
"https://api.github.com.evil.example",
77+
"not a url",
78+
]) {
79+
await expect(
80+
pollCheckRuns("acme/widgets", 42, {
81+
apiBaseUrl,
82+
githubToken: "github-token",
83+
fetchFn,
84+
}),
85+
).rejects.toThrow("invalid_api_base_url");
86+
}
87+
expect(fetchFn).not.toHaveBeenCalled();
88+
});
89+
90+
it("uses the default GitHub API base URL when apiBaseUrl is omitted", async () => {
91+
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
92+
const url = String(input);
93+
if (url === "https://api.github.com/repos/acme/widgets/pulls/42") return prResponse("head-sha");
94+
if (url === "https://api.github.com/repos/acme/widgets/commits/head-sha/check-runs?per_page=100&page=1") {
95+
return checksResponse([checkRun("validate", "completed", "success")]);
96+
}
97+
return jsonResponse({}, { status: 404 });
98+
});
99+
100+
await expect(pollCheckRuns("acme/widgets", 42, { fetchFn })).resolves.toMatchObject({
101+
conclusion: "success",
102+
});
103+
});
104+
71105
it("follows paginated check-run responses before aggregating failures (regression for #2621)", async () => {
72106
const pageOneChecks = Array.from({ length: 100 }, (_, index) =>
73107
checkRun(`success-${index}`, "completed", "success"),

0 commit comments

Comments
 (0)