| 1 | import { readFileSync } from "node:fs"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | import type { Env } from "../../src/env.js"; |
| 4 | import type { Principal } from "../../src/auth/principal.js"; |
| 5 | import { bucketsFor, MODEL_RATE_LIMIT, modelRateLimited, overLimit, RATE_LIMITS, RATE_PERIOD_S, type RateLimiter } from "../../src/lib/rate-limit.js"; |
| 6 | |
| 7 | const anon: Principal = { type: "anonymous" }; |
| 8 | const person = { type: "account", account: { id: "u1" }, via: "token", scopes: ["write"], repoIds: null } as unknown as Principal; |
| 9 | const job = { type: "job", repoId: "r1" } as unknown as Principal; |
| 10 | |
| 11 | /** A limiter that allows `n` calls per key. */ |
| 12 | function limiter(n: number): RateLimiter & { seen: string[] } { |
| 13 | const counts = new Map<string, number>(); |
| 14 | const seen: string[] = []; |
| 15 | return { |
| 16 | seen, |
| 17 | async limit({ key }) { |
| 18 | seen.push(key); |
| 19 | const c = (counts.get(key) ?? 0) + 1; |
| 20 | counts.set(key, c); |
| 21 | return { success: c <= n }; |
| 22 | }, |
| 23 | }; |
| 24 | } |
| 25 | |
| 26 | describe("rate limit buckets", () => { |
| 27 | it("counts sign-in attempts per IP address, and other anonymous requests separately", () => { |
| 28 | expect(bucketsFor("POST", "/auth/passkey/login/options", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 29 | expect(bucketsFor("POST", "/invite/abc/email", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 30 | expect(bucketsFor("POST", "/signup", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 31 | expect(bucketsFor("POST", "/signup/verify", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 32 | // Security reports, signed in or not, count per IP address like sign-up. |
| 33 | expect(bucketsFor("POST", "/security/report", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 34 | for (const path of ["/early-access", "/early-access/confirm", "/early-access/remove", "/early-access/remove/confirm"]) expect(bucketsFor("POST", path, anon, "203.0.113.9"), path).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 35 | expect(bucketsFor("GET", "/early-access", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); |
| 36 | expect(bucketsFor("POST", "/security/report", person, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]); |
| 37 | expect(bucketsFor("GET", "/security/report", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); |
| 38 | expect(bucketsFor("GET", "/signup", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); |
| 39 | expect(bucketsFor("GET", "/login", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); |
| 40 | expect(bucketsFor("GET", "/api/v3/repos/acme/app", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]); |
| 41 | expect(bucketsFor("GET", "/acme/app.git/info/refs", anon, "198.51.100.1")).toEqual([["RL_ANON", "ip:198.51.100.1"]]); |
| 42 | }); |
| 43 | |
| 44 | it("counts a signed-in account's requests, and its new issues, comments and repositories again", () => { |
| 45 | expect(bucketsFor("GET", "/api/v3/user/repos", person, "x")).toEqual([["RL_ACCOUNT", "acct:u1"]]); |
| 46 | for (const [m, path] of [ |
| 47 | ["POST", "/api/v3/user/repos"], |
| 48 | ["POST", "/api/v3/orgs/acme/repos"], |
| 49 | ["POST", "/api/v3/user/orgs"], |
| 50 | ["POST", "/api/v3/repos/acme/app/issues"], |
| 51 | ["POST", "/api/v3/repos/acme/app/issues/3/comments"], |
| 52 | ["POST", "/api/v3/repos/acme/app/pulls/4/reviews"], |
| 53 | ["POST", "/api/v3/repos/acme/app/pulls/4/comments/9/replies"], |
| 54 | ["POST", "/api/v3/repos/acme/app/commits/abc123/comments"], |
| 55 | ["POST", "/new"], |
| 56 | ["POST", "/acme/app/issues"], |
| 57 | ["POST", "/acme/app/issues/3/comment"], |
| 58 | ["POST", "/acme/app/pull/4/review"], |
| 59 | ["POST", "/api/v3/diffinery/reports"], |
| 60 | ["POST", "/-/report"], |
| 61 | ]) expect(bucketsFor(m!, path!, person, "x"), path).toEqual([["RL_ACCOUNT", "acct:u1"], ["RL_CREATE", "acct:u1"]]); |
| 62 | expect(bucketsFor("PATCH", "/api/v3/repos/acme/app/issues/3", person, "x")).toEqual([["RL_ACCOUNT", "acct:u1"]]); |
| 63 | }); |
| 64 | |
| 65 | it("leaves runners, the Actions runtime, health checks and avatars alone", () => { |
| 66 | for (const path of ["/healthz", "/api/avatars/u1", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", "/_runtime/x", "/runner/jobs/claim", "/-/model/anthropic/v1/messages"]) expect(bucketsFor("POST", path, anon, "x"), path).toEqual([]); |
| 67 | expect(bucketsFor("GET", "/api/v3/repos/acme/app", job, "x")).toEqual([]); |
| 68 | }); |
| 69 | }); |
| 70 | |
| 71 | describe("rate limit checks", () => { |
| 72 | it("refuses once a bucket is used up, per key", async () => { |
| 73 | const rl = limiter(2); |
| 74 | const env = { RL_ANON: rl } as unknown as Env; |
| 75 | const a = bucketsFor("GET", "/", anon, "203.0.113.9"); |
| 76 | expect(await overLimit(env, a)).toBeNull(); |
| 77 | expect(await overLimit(env, a)).toBeNull(); |
| 78 | expect(await overLimit(env, a)).toBe("RL_ANON"); |
| 79 | expect(await overLimit(env, bucketsFor("GET", "/", anon, "203.0.113.10"))).toBeNull(); |
| 80 | }); |
| 81 | |
| 82 | it("names the create bucket when an account's general budget still has room", async () => { |
| 83 | const env = { RL_ACCOUNT: limiter(100), RL_CREATE: limiter(1) } as unknown as Env; |
| 84 | const b = bucketsFor("POST", "/api/v3/repos/acme/app/issues", person, "x"); |
| 85 | expect(await overLimit(env, b)).toBeNull(); |
| 86 | expect(await overLimit(env, b)).toBe("RL_CREATE"); |
| 87 | }); |
| 88 | |
| 89 | it("lets requests through when no limiter is configured or the limiter fails", async () => { |
| 90 | expect(await overLimit({} as Env, [["RL_ANON", "ip:x"]])).toBeNull(); |
| 91 | const broken = { RL_ANON: { limit: async () => Promise.reject(new Error("down")) } } as unknown as Env; |
| 92 | expect(await overLimit(broken, [["RL_ANON", "ip:x"]])).toBeNull(); |
| 93 | }); |
| 94 | |
| 95 | it("matches the limits configured for production", () => { |
| 96 | const text = readFileSync(new URL("../../wrangler.jsonc", import.meta.url), "utf8"); |
| 97 | for (const [name, limit] of Object.entries(RATE_LIMITS)) { |
| 98 | const line = text.split("\n").find((l) => l.includes(`"name": "${name}"`)) ?? ""; |
| 99 | const m = /"limit": (\d+), "period": (\d+)/.exec(line); |
| 100 | expect(m, name).not.toBeNull(); |
| 101 | expect([Number(m![1]), Number(m![2])], name).toEqual([limit, RATE_PERIOD_S]); |
| 102 | } |
| 103 | }); |
| 104 | }); |
| 105 | |
| 106 | describe("rate limits in the Worker", () => { |
| 107 | // Importing the whole Worker takes a few seconds when the suite runs in parallel. |
| 108 | it("answers 429 with retry-after once an anonymous address is over its limit", { timeout: 30_000 }, async () => { |
| 109 | const { app } = await import("../../src/app.js"); |
| 110 | const env = { DB: {}, PUBLIC_URL: "https://d.example", RL_ANON: limiter(1) } as unknown as Env; |
| 111 | const ctx = { waitUntil: () => {}, passThroughOnException: () => {}, props: {} } as unknown as ExecutionContext; |
| 112 | const get = () => app.request("/healthz-not-exempt", { headers: { "cf-connecting-ip": "203.0.113.9" } }, env, ctx); |
| 113 | expect((await get()).status).toBe(404); |
| 114 | const r = await get(); |
| 115 | expect(r.status).toBe(429); |
| 116 | expect(r.headers.get("retry-after")).toBe("60"); |
| 117 | expect(r.headers.get("strict-transport-security")).toBeTruthy(); |
| 118 | expect(((await r.json()) as { message: string }).message).toMatch(/at most 300 a minute/); |
| 119 | }); |
| 120 | }); |
| 121 | |
| 122 | describe("model requests per paying person (phase 5a)", () => { |
| 123 | it("counts per person, and lets requests through without a binding or when it fails", async () => { |
| 124 | const rl = limiter(2); |
| 125 | const env = { RL_MODEL: rl } as unknown as Env; |
| 126 | expect(await modelRateLimited(env, "p1")).toBe(false); |
| 127 | expect(await modelRateLimited(env, "p1")).toBe(false); |
| 128 | expect(await modelRateLimited(env, "p1")).toBe(true); |
| 129 | expect(await modelRateLimited(env, "p2")).toBe(false); |
| 130 | expect(rl.seen).toEqual(["model:p1", "model:p1", "model:p1", "model:p2"]); |
| 131 | expect(await modelRateLimited({} as Env, "p1")).toBe(false); |
| 132 | const broken = { RL_MODEL: { limit: async () => { throw new Error("down"); } } } as unknown as Env; |
| 133 | expect(await modelRateLimited(broken, "p1")).toBe(false); |
| 134 | }); |
| 135 | |
| 136 | it("matches wrangler.jsonc, and the model proxy is not in the per-account buckets", () => { |
| 137 | const text = readFileSync(new URL("../../wrangler.jsonc", import.meta.url), "utf8"); |
| 138 | const line = text.split("\n").find((l) => l.includes('"name": "RL_MODEL"')) ?? ""; |
| 139 | expect(/"limit": (\d+), "period": (\d+)/.exec(line)?.slice(1).map(Number)).toEqual([MODEL_RATE_LIMIT, RATE_PERIOD_S]); |
| 140 | expect(bucketsFor("POST", "/-/model/anthropic/v1/messages", person, "203.0.113.9")).toEqual([]); |
| 141 | }); |
| 142 | }); |