diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 5bd04f3df..a5f543d37 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -13993,7 +13993,7 @@ ], "responses": { "200": { - "description": "Public GitHub repository stars/forks for the website chrome; any syntactically-valid owner/repo is accepted unless the deployment configures PUBLIC_REPO_STATS_ALLOWLIST.", + "description": "Public GitHub repository stars/forks for the website chrome; PUBLIC_REPO_STATS_ALLOWLIST must explicitly include the owner/repo.", "content": { "application/json": { "schema": { diff --git a/src/env.d.ts b/src/env.d.ts index 73fcf1129..86a9cf7f4 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -178,11 +178,11 @@ declare global { GITTENSOR_REGISTRY_URL: string; GITHUB_PUBLIC_TOKEN?: string; /** Public repo-stats endpoint (#4612): comma-separated allowlist of "owner/repo" pairs (case-insensitive) - * permitted to query GET /v1/public/github/repos/:owner/:repo/stats. Default "" (unset) => allow any - * syntactically-valid owner/repo -- this route proxies the live GitHub API rather than gittensory's own - * installation DB, so (unlike the sibling badge.svg route) there is no "installed" gate to fall back on - * instead. Set this to restrict a deployment to specific repos. Mirrors GITTENSORY_PUBLIC_STATS_REPOS's - * comma-separated shape (src/review/public-stats.ts). See src/github/public.ts. */ + * permitted to query GET /v1/public/github/repos/:owner/:repo/stats. Default "" (unset) denies all repos + * because this unauthenticated route proxies the live GitHub API with the deployment's server-side token. + * Set this to the exact public repos that should expose website chrome stats. Mirrors + * GITTENSORY_PUBLIC_STATS_REPOS's comma-separated shape (src/review/public-stats.ts). See + * src/github/public.ts. */ PUBLIC_REPO_STATS_ALLOWLIST?: string; /** #703: owner-gated global to apply upstream sigmoid time-decay in score previews. Default off. */ SCORING_TIME_DECAY_ENABLED?: string; diff --git a/src/github/public.ts b/src/github/public.ts index 2f538d997..0db90a5a5 100644 --- a/src/github/public.ts +++ b/src/github/public.ts @@ -159,9 +159,9 @@ export function clearPublicRepoStatsCacheForTests(): void { } // Parses PUBLIC_REPO_STATS_ALLOWLIST into a lowercased "owner/repo" set, mirroring publicStatsProjects's -// comma-separated parsing in src/review/public-stats.ts. Empty/unset -> empty set -> no allowlist restriction -// (#4612): a self-hosted fork's own repo works out of the box, with no code change, exactly like the sibling -// badge.svg route already does for any installed repo. +// comma-separated parsing in src/review/public-stats.ts. Empty/unset -> empty set -> deny all public +// repo-stats requests so the unauthenticated route cannot proxy arbitrary GitHub API calls with the +// deployment's server-side token. function publicRepoStatsAllowlist(env: Pick): Set { const allowlist = new Set(); for (const entry of (env.PUBLIC_REPO_STATS_ALLOWLIST ?? "").split(",")) { @@ -180,7 +180,7 @@ function publicRepoFullName(env: Pick, owner const normalizedRepoName = repoName.toLowerCase(); const normalizedFullName = `${normalizedOwnerName}/${normalizedRepoName}`; const allowlist = publicRepoStatsAllowlist(env); - if (allowlist.size > 0 && !allowlist.has(normalizedFullName)) throw new Error("invalid_github_repo"); + if (!allowlist.has(normalizedFullName)) throw new Error("invalid_github_repo"); return normalizedFullName; } diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 434fcf8aa..14ad4892c 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -185,7 +185,7 @@ export function buildOpenApiSpec() { path: "/v1/public/github/repos/{owner}/{repo}/stats", request: { params: z.object({ owner: z.string(), repo: z.string() }) }, responses: { - 200: { description: "Public GitHub repository stars/forks for the website chrome; any syntactically-valid owner/repo is accepted unless the deployment configures PUBLIC_REPO_STATS_ALLOWLIST.", content: { "application/json": { schema: PublicRepoStatsSchema } } }, + 200: { description: "Public GitHub repository stars/forks for the website chrome; PUBLIC_REPO_STATS_ALLOWLIST must explicitly include the owner/repo.", content: { "application/json": { schema: PublicRepoStatsSchema } } }, 400: { description: "Invalid or non-allowlisted GitHub repository" }, 503: { description: "GitHub repository stats are unavailable" }, }, diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 0133f0f4f..bce937d19 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -126,7 +126,7 @@ describe("api routes", () => { it("serves public GitHub repo stats without relying on browser GitHub quota", async () => { const app = createApp(); - const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token", PUBLIC_REPO_STATS_ALLOWLIST: "JSONbored/gittensory" }); const calls: Array<{ url: string; authorization?: string }> = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); @@ -158,8 +158,10 @@ describe("api routes", () => { it("REGRESSION (#regression-safe-propagation): serves public GitHub repo stats when GITHUB_PUBLIC_TOKEN is unset, still unauthenticated (self-host operators can run without it)", async () => { const app = createApp(); // No GITHUB_PUBLIC_TOKEN at all -- exercises fetchRepoStatsFromGitHub's admission-key "token absent" branch, - // distinct from the "token present" branch every other stats test in this file exercises. - const env = createTestEnv({}); + // distinct from the "token present" branch every other stats test in this file exercises. The allowlist + // entry is required independently of that concern (an unset PUBLIC_REPO_STATS_ALLOWLIST now denies all + // repos by default) so this test keeps isolating the token-absent behavior it's actually regression-testing. + const env = createTestEnv({ PUBLIC_REPO_STATS_ALLOWLIST: "acme/anon" }); const calls: Array<{ url: string; authorization: string | null }> = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const authorization = new Headers(init?.headers).get("authorization"); @@ -173,27 +175,16 @@ describe("api routes", () => { expect(calls).toEqual([{ url: "https://api.github.com/repos/acme/anon", authorization: null }]); }); - it("serves public GitHub repo stats for any repo when PUBLIC_REPO_STATS_ALLOWLIST is unset (#4612)", async () => { + it("rejects public GitHub repo stats when PUBLIC_REPO_STATS_ALLOWLIST is unset", async () => { const app = createApp(); - // No PUBLIC_REPO_STATS_ALLOWLIST override: a self-hoster's own fork must work without code changes. const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); - const calls: string[] = []; - vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { - calls.push(input.toString()); - return Response.json({ full_name: "acme/widgets", html_url: "https://github.com/acme/widgets", stargazers_count: 5, forks_count: 1 }); - }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); const response = await app.request("/v1/public/github/repos/acme/widgets/stats", {}, env); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - repoFullName: "acme/widgets", - htmlUrl: "https://github.com/acme/widgets", - stargazers_count: 5, - forks_count: 1, - source: "github", - stale: false, - }); - expect(calls).toEqual(["https://api.github.com/repos/acme/widgets"]); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_github_repo" }); + expect(fetchMock).not.toHaveBeenCalled(); }); it("rejects public GitHub repo stats for repos outside a configured PUBLIC_REPO_STATS_ALLOWLIST, tolerating whitespace/casing/empty entries (#4612)", async () => { @@ -223,7 +214,7 @@ describe("api routes", () => { it("normalizes allowlisted public GitHub repo stats casing before fetching and caching", async () => { const app = createApp(); - const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token", PUBLIC_REPO_STATS_ALLOWLIST: "JSONbored/gittensory" }); const calls: string[] = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { calls.push(input.toString()); @@ -250,7 +241,7 @@ describe("api routes", () => { it("serves stale public repo stats instead of failing during transient GitHub errors", async () => { const app = createApp(); - const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token", PUBLIC_REPO_STATS_ALLOWLIST: "JSONbored/gittensory" }); vi.stubGlobal("fetch", async () => Response.json({ full_name: "JSONbored/gittensory", html_url: "https://github.com/JSONbored/gittensory", stargazers_count: 21, forks_count: 4 })); const fresh = await app.request("/v1/public/github/repos/JSONbored/gittensory/stats", {}, env); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index b3c449102..9e544fcbf 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: e01a02a550c5fe9e8fe3625c9f9338bf) +// Generated by Wrangler by running `wrangler types` (hash: 900b52f9438d43ca573a88169ce5c47f) // Runtime types generated with workerd@1.20260701.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -44,6 +44,7 @@ interface __BaseEnv_Env { GITTENSORY_REVIEW_REPOS: ""; GITTENSORY_PUBLIC_STATS: "true"; GITTENSORY_PUBLIC_STATS_REPOS: "JSONbored/gittensory,JSONbored/awesome-claude,JSONbored/metagraphed"; + PUBLIC_REPO_STATS_ALLOWLIST: "JSONbored/gittensory"; GITTENSORY_DUPLICATE_WINNER: "true"; GITTENSORY_OPEN_PR_FILE_COLLISION: "true"; GITTENSORY_SKIP_AUTOMATION_BOT_PRS: "true"; @@ -103,6 +104,7 @@ declare namespace NodeJS { | "GITTENSORY_SKIP_AUTOMATION_BOT_PRS" | "GITTENSORY_SWEEP_WATCHDOG" | "PUBLIC_API_ORIGIN" + | "PUBLIC_REPO_STATS_ALLOWLIST" | "PUBLIC_SITE_ORIGIN" | "SCORING_TIME_DECAY_ENABLED" >> {} diff --git a/wrangler.jsonc b/wrangler.jsonc index 7f1011ebe..bf9af8f7e 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -192,6 +192,9 @@ // src/env.d.ts for the full split rationale). These three repos' historical audit_events rows remain real and // safe to publish even though the cutover allowlist above no longer lists them. "GITTENSORY_PUBLIC_STATS_REPOS": "JSONbored/gittensory,JSONbored/awesome-claude,JSONbored/metagraphed", + // Website chrome repo stats are public and proxied through the server-side GitHub token, so require + // an explicit narrow allowlist instead of letting unset config proxy arbitrary owner/repo pairs. + "PUBLIC_REPO_STATS_ALLOWLIST": "JSONbored/gittensory", // Duplicate-winner adjudication (#dup-winner): when a same-issue cluster of OPEN PRs forms, spare exactly // ONE winner — the lowest-numbered (earliest opened) open PR — instead of blocking/closing every sibling. // Only the LOSERS get the duplicate blocker + close reason + slop penalty + panel block; the winner is