diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index c4db48121f..f4d5bdbb05 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -14551,7 +14551,7 @@ ], "responses": { "200": { - "description": "Public GitHub repository stars/forks for website chrome", + "description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", "content": { "application/json": { "schema": { @@ -14561,7 +14561,7 @@ } }, "400": { - "description": "Invalid GitHub repository" + "description": "Invalid or non-allowlisted GitHub repository" }, "503": { "description": "GitHub repository stats are unavailable" diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index 34fd46afcc..c30fd7904e 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -117,7 +117,10 @@ export function routeClassForPath(path: string): RateLimitClass { } async function rateLimitKey(c: Context<{ Bindings: Env }>, routeClass: RateLimitClass): Promise { - const pathGroup = c.req.path.replace(/\/\d+(?=\/|$)/g, "/:number").replace(/\/[^/]+\/[^/]+\/pulls\//, "/:owner/:repo/pulls/"); + const pathGroup = c.req.path + .replace(/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/, "/v1/public/github/repos/:owner/:repo/stats") + .replace(/\/\d+(?=\/|$)/g, "/:number") + .replace(/\/[^/]+\/[^/]+\/pulls\//, "/:owner/:repo/pulls/"); const identity = await rateLimitIdentity(c); return `${routeClass}:${pathGroup}:${identity}`; } diff --git a/src/github/public.ts b/src/github/public.ts index b55258fe1b..e78c96843d 100644 --- a/src/github/public.ts +++ b/src/github/public.ts @@ -49,6 +49,8 @@ type RepoStatsCacheEntry = { staleUntilMs: number; }; +const PUBLIC_REPO_STATS_OWNER = "jsonbored"; +const PUBLIC_REPO_STATS_REPO = "gittensory"; const REPO_STATS_CACHE_TTL_MS = 1000 * 60 * 10; const REPO_STATS_STALE_TTL_MS = 1000 * 60 * 60 * 24; const repoStatsCache = new Map(); @@ -124,7 +126,10 @@ function publicRepoFullName(owner: string, repo: string): string { const repoName = repo.trim(); if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(ownerName)) throw new Error("invalid_github_repo"); if (!/^[A-Za-z0-9._-]{1,100}$/.test(repoName) || repoName === "." || repoName === "..") throw new Error("invalid_github_repo"); - return `${ownerName}/${repoName}`; + const normalizedOwnerName = ownerName.toLowerCase(); + const normalizedRepoName = repoName.toLowerCase(); + if (normalizedOwnerName !== PUBLIC_REPO_STATS_OWNER || normalizedRepoName !== PUBLIC_REPO_STATS_REPO) throw new Error("invalid_github_repo"); + return `${normalizedOwnerName}/${normalizedRepoName}`; } async function fetchRepoStatsFromGitHub(env: Pick, repoFullName: string, nowMs: number): Promise { diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 6c53e1e351..d2f2d32823 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -172,8 +172,8 @@ 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 website chrome", content: { "application/json": { schema: PublicRepoStatsSchema } } }, - 400: { description: "Invalid GitHub repository" }, + 200: { description: "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", 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 dbba742764..4d03dfe2c1 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -127,7 +127,7 @@ describe("api routes", () => { source: "github", stale: false, }); - expect(calls).toEqual([{ url: "https://api.github.com/repos/JSONbored/gittensory", authorization: "Bearer public-token" }]); + expect(calls).toEqual([{ url: "https://api.github.com/repos/jsonbored/gittensory", authorization: "Bearer public-token" }]); const cached = await app.request("/v1/public/github/repos/JSONbored/gittensory/stats", {}, env); expect(cached.status).toBe(200); @@ -135,6 +135,33 @@ describe("api routes", () => { expect(calls).toHaveLength(1); }); + 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 calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + calls.push(input.toString()); + return Response.json({ stargazers_count: 8, forks_count: 2 }); + }); + + const response = await app.request("/v1/public/github/repos/JsonBored/GittenSory/stats", {}, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + repoFullName: "jsonbored/gittensory", + htmlUrl: "https://github.com/jsonbored/gittensory", + stargazers_count: 8, + forks_count: 2, + source: "github", + stale: false, + }); + expect(calls).toEqual(["https://api.github.com/repos/jsonbored/gittensory"]); + + const cached = await app.request("/v1/public/github/repos/JSONBORED/GITTENSORY/stats", {}, env); + expect(cached.status).toBe(200); + await expect(cached.json()).resolves.toMatchObject({ stargazers_count: 8, forks_count: 2, source: "cache", stale: false }); + expect(calls).toHaveLength(1); + }); + 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" }); @@ -168,6 +195,18 @@ describe("api routes", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("rejects non-allowlisted public GitHub repo stats paths before calling GitHub", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await app.request("/v1/public/github/repos/Attacker/missing-one/stats", {}, env); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_github_repo" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("serves registry drift through the canonical registry change endpoint", async () => { const app = createApp(); const env = createTestEnv(); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 62729e7ca4..e7051d885d 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -131,6 +131,13 @@ describe("private-beta auth and rate limiting", () => { ).resolves.toBeNull(); expect(observedKeys[0]).toMatch(/^normal:\/v1\/repos:ip:/); expect(observedKeys[1]).toMatch(/^normal:\/v1\/repos:token:/); + + observedKeys.length = 0; + await expect(enforceRateLimit(fakeContext(env, "/v1/public/github/repos/JSONbored/gittensory/stats", { "cf-connecting-ip": "203.0.113.9" }), "normal")).resolves.toBeNull(); + await expect(enforceRateLimit(fakeContext(env, "/v1/public/github/repos/Attacker/missing-one/stats", { "cf-connecting-ip": "203.0.113.9" }), "normal")).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + expect(observedKeys[0]).toMatch(/^normal:\/v1\/public\/github\/repos\/:owner\/:repo\/stats:ip:/); }); it("enforces route limits with session and IP keys plus retry headers", async () => {