Skip to content
178 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { readFileSync } from "node:fs";
3import { test } from "node:test";
4
5import {
6 LIMIT_PERIOD_SECONDS,
7 RATE_LIMITS,
8 type RateLimitBinding,
9 checkLimit,
10 isLimited,
11 secretKey,
12} from "@g1t/contracts/rate-limits";
13
14import { gitLimited, heavy, pageLimited, sessionCookie, unlimited } from "./front-door-limits.ts";
15
16/** A binding that lets `allow` requests through per key, recording each key it was asked. */
17function binding(allow: number): RateLimitBinding & { keys: string[] } {
18 const counts = new Map<string, number>();
19 const keys: string[] = [];
20 return {
21 keys,
22 async limit({ key }) {
23 keys.push(key);
24 const count = (counts.get(key) ?? 0) + 1;
25 counts.set(key, count);
26 return { success: count <= allow };
27 },
28 };
29}
30
31const broken: RateLimitBinding = {
32 async limit() {
33 throw new Error("the binding is down");
34 },
35};
36
37function request(path: string, headers: Record<string, string> = {}): Request {
38 return new Request(`https://g1t.sh${path}`, { headers: { "cf-connecting-ip": "203.0.113.9", ...headers } });
39}
40
41test("a limit lets requests through until it is reached", async () => {
42 const limit = binding(2);
43 assert.equal(await checkLimit(limit, "ip:1"), "allowed");
44 assert.equal(await checkLimit(limit, "ip:1"), "allowed");
45 assert.equal(await checkLimit(limit, "ip:1"), "limited");
46 assert.equal(await checkLimit(limit, "ip:2"), "allowed", "each key counts apart");
47});
48
49test("a missing or failing binding fails open", async () => {
50 const logged = console.error;
51 console.error = () => {};
52 try {
53 assert.equal(await checkLimit(undefined, "ip:1"), "unavailable");
54 assert.equal(await checkLimit(broken, "ip:1"), "unavailable");
55 assert.equal(await isLimited(broken, "ip:1"), false);
56 assert.equal(await gitLimited({ GIT_ANONYMOUS_LIMIT: broken }, request("/acme/rocket.git/info/refs")), null);
57 assert.equal(await pageLimited({ WEB_ADDRESS_LIMIT: broken, WEB_ANONYMOUS_LIMIT: broken }, request("/acme/rocket"), "/acme/rocket"), null);
58 } finally {
59 console.error = logged;
60 }
61});
62
63test("secrets are keyed by a short hash, never as they are", async () => {
64 const key = await secretKey("session", "s3cret-session");
65 assert.match(key, /^session:[0-9a-f]{16}$/);
66 assert.equal(await secretKey("session", "s3cret-session"), key);
67 assert.notEqual(await secretKey("session", "another"), key);
68});
69
70test("git without credentials is limited by address, with a message git shows", async () => {
71 const env = { GIT_ANONYMOUS_LIMIT: binding(1), GIT_SIGNED_LIMIT: binding(1) };
72 const clone = () => request("/acme/rocket.git/info/refs");
73 assert.equal(await gitLimited(env, clone()), null);
74 const refused = await gitLimited(env, clone());
75 assert.equal(refused?.status, 429);
76 assert.equal(refused?.headers.get("retry-after"), String(LIMIT_PERIOD_SECONDS));
77 assert.match(refused?.headers.get("content-type") ?? "", /^text\/plain/);
78 assert.match((await refused?.text()) ?? "", /Too many git requests/);
79 assert.deepEqual(env.GIT_ANONYMOUS_LIMIT.keys, ["ip:203.0.113.9", "ip:203.0.113.9"]);
80});
81
82test("git with credentials counts by a hash of them, apart from the address", async () => {
83 const env = { GIT_ANONYMOUS_LIMIT: binding(0), GIT_SIGNED_LIMIT: binding(5) };
84 const authorization = `Basic ${btoa("ada:g1t_token")}`;
85 assert.equal(await gitLimited(env, request("/acme/rocket.git/git-upload-pack", { authorization })), null);
86 assert.equal(env.GIT_ANONYMOUS_LIMIT.keys.length, 0);
87 assert.match(env.GIT_SIGNED_LIMIT.keys[0]!, /^git:[0-9a-f]{16}$/);
88 assert.ok(!env.GIT_SIGNED_LIMIT.keys[0]!.includes("g1t_token"));
89});
90
91test("signed-out pages count by address, and costly ones against a tighter limit too", async () => {
92 const env = { WEB_ADDRESS_LIMIT: binding(100), WEB_ANONYMOUS_LIMIT: binding(100), WEB_HEAVY_LIMIT: binding(1) };
93 assert.equal(await pageLimited(env, request("/acme/rocket"), "/acme/rocket"), null);
94 assert.equal(env.WEB_HEAVY_LIMIT.keys.length, 0);
95 const archive = "/acme/rocket/archive/main.zip";
96 assert.equal(await pageLimited(env, request(archive), archive), null);
97 const refused = await pageLimited(env, request(archive), archive);
98 assert.equal(refused?.status, 429);
99 assert.match((await refused?.text()) ?? "", /Signed-in accounts have a higher limit/);
100 assert.equal(await pageLimited(env, request("/acme/rocket/issues"), "/acme/rocket/issues"), null, "other pages go on");
101});
102
103test("signed-in requests count by session, and every request by address", async () => {
104 const env = { WEB_ADDRESS_LIMIT: binding(1), WEB_SESSION_LIMIT: binding(100), WEB_ANONYMOUS_LIMIT: binding(0) };
105 const signedIn = () => request("/acme/rocket/archive/main.zip", { cookie: "theme=dark; g1t_session=abc123" });
106 assert.equal(await pageLimited(env, signedIn(), "/acme/rocket/archive/main.zip"), null);
107 assert.equal(env.WEB_ANONYMOUS_LIMIT.keys.length, 0);
108 assert.match(env.WEB_SESSION_LIMIT.keys[0]!, /^session:[0-9a-f]{16}$/);
109 // Made-up cookies still meet the ceiling per address.
110 const refused = await pageLimited(env, request("/", { cookie: "g1t_session=made-up" }), "/");
111 assert.equal(refused?.status, 429);
112});
113
114test("files the Worker serves itself are never limited", async () => {
115 const env = { WEB_ADDRESS_LIMIT: binding(0), WEB_ANONYMOUS_LIMIT: binding(0) };
116 for (const path of ["/assets/app-1a2b.js", "/fonts/hanken.woff2", "/favicon.ico", "/robots.txt", "/llms.txt", "/sitemap.xml"]) {
117 assert.ok(unlimited(path), path);
118 assert.equal(await pageLimited(env, request(path), path), null, path);
119 }
120 assert.ok(!unlimited("/acme/rocket/blob/main/logo.png"), "a file in a repository is a page");
121});
122
123test("costly pages are recognised", () => {
124 for (const path of [
125 "/search",
126 "/search.data",
127 "/acme/rocket/archive/main.zip",
128 "/acme/rocket/actions/runs/run_1",
129 "/acme/rocket/actions/runs/run_1.data",
130 "/acme/rocket/actions/runs/run_1/logs.zip",
131 "/acme/rocket/actions/runs/run_1/artifacts/dist",
132 "/acme/rocket/actions/jobs/job_1/log.txt",
133 ]) {
134 assert.ok(heavy(path), path);
135 }
136 for (const path of ["/", "/acme/rocket", "/acme/rocket/actions", "/acme/rocket/issues/1", "/acme/search"]) {
137 assert.ok(!heavy(path), path);
138 }
139});
140
141test("the session cookie is read from among others", () => {
142 assert.equal(sessionCookie("theme=dark; g1t_session=abc; x=1"), "abc");
143 assert.equal(sessionCookie("g1t_session=abc"), "abc");
144 assert.equal(sessionCookie("not_g1t_session=abc"), null);
145 assert.equal(sessionCookie(null), null);
146});
147
148/** A wrangler.jsonc as JSON: comments and trailing commas out, strings kept. */
149function readJsonc(path: string): { ratelimits?: { name: string; namespace_id: string; simple: { limit: number; period: number } }[] } {
150 const text = readFileSync(new URL(path, import.meta.url), "utf8")
151 .replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, string: string | undefined) => string ?? "")
152 .replace(/,(\s*[}\]])/g, "$1");
153 return JSON.parse(text);
154}
155
156test("every wrangler.jsonc declares the rate limits RATE_LIMITS lists, and only those", () => {
157 const ids = new Set<number>();
158 const workers = new Set(Object.values(RATE_LIMITS).map((spec) => spec.worker));
159 for (const worker of workers) {
160 const declared = readJsonc(`../../../../${worker}/wrangler.jsonc`).ratelimits ?? [];
161 const listed = Object.entries(RATE_LIMITS).filter(([, spec]) => spec.worker === worker);
162 assert.deepEqual(
163 declared.map((binding) => binding.name).sort(),
164 listed.map(([name]) => name).sort(),
165 `${worker}'s bindings`,
166 );
167 for (const [name, spec] of listed) {
168 const binding = declared.find((b) => b.name === name)!;
169 assert.equal(Number(binding.namespace_id), spec.namespaceId, `${name}'s namespace id`);
170 assert.equal(binding.simple.limit, spec.limit, `${name}'s limit`);
171 assert.equal(binding.simple.period, LIMIT_PERIOD_SECONDS, `${name}'s period`);
172 }
173 }
174 for (const spec of Object.values(RATE_LIMITS)) {
175 assert.ok(!ids.has(spec.namespaceId), `namespace id ${spec.namespaceId} is used once`);
176 ids.add(spec.namespaceId);
177 }
178});