From 9f9fa6b1b1bb2ca129602857dc84b793f8073ba0 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:40:11 -0700 Subject: [PATCH 1/4] fix(github): require repo stats allowlist --- apps/gittensory-ui/public/openapi.json | 2 +- src/env.d.ts | 10 ++++---- src/github/public.ts | 8 +++---- src/openapi/spec.ts | 2 +- test/integration/api.test.ts | 33 ++++++++++---------------- worker-configuration.d.ts | 4 +++- wrangler.jsonc | 3 +++ 7 files changed, 29 insertions(+), 33 deletions(-) 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..e45a1de9d 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()); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index b3c449102..a040d829c 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: PLACEHOLDER) // 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 From 91092819d838e76cb0331fb23d4b2899f29bd168 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:08:57 -0700 Subject: [PATCH 2/4] fix(github): keep two pre-existing tests compatible with the new default-deny allowlist Two tests in test/integration/api.test.ts called the public repo-stats route without configuring PUBLIC_REPO_STATS_ALLOWLIST, relying on the old "unset allowlist = allow any repo" behavior this PR intentionally removes (unset now denies all, closing the unauthenticated-proxy gap). Added an explicit allowlist entry to each so they keep testing their own actual concern (GITHUB_PUBLIC_TOKEN absence; transient-error stale-cache fallback) independently of the new allowlist requirement, rather than being confounded by it. Also resolves the rebase onto current main (a real content conflict in the allowlist test naming, and a mechanical worker-configuration.d.ts regeneration). --- test/integration/api.test.ts | 2 +- worker-configuration.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index e45a1de9d..bce937d19 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -241,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 a040d829c..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: PLACEHOLDER) +// 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; From 653dd1ede37c54424b0bd5d90d1e90b74f4c6a7e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:29:44 -0700 Subject: [PATCH 3/4] fix(github): hand-add PUBLIC_REPO_STATS_ALLOWLIST to worker-configuration.d.ts The prior regeneration (via npm run cf-typegen, resolving the rebase conflict) pulled in ~176 lines of unrelated drift from this local machine's wrangler/ workerd cache being out of sync with whatever last regenerated main's copy. Reset to origin/main's exact file, then hand-inserted just the one new binding this PR's wrangler.jsonc change actually needs (PUBLIC_REPO_STATS_ALLOWLIST, in both the literal-value interface and the sorted ProcessEnv union), mirroring the exact declaration position/format of the adjacent existing entries. --- worker-configuration.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 9e544fcbf..5aa9ab402 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: 900b52f9438d43ca573a88169ce5c47f) +// Generated by Wrangler by running `wrangler types` (hash: e01a02a550c5fe9e8fe3625c9f9338bf) // Runtime types generated with workerd@1.20260701.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { DB: D1Database; From 7569796676b5829488db3833006bc920bbe57997 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:25:10 -0700 Subject: [PATCH 4/4] fix(github): regenerate worker-configuration.d.ts against current main --- worker-configuration.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 5aa9ab402..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;