Public: anyone can read this; the crew can change it.

Strangers cannot exhaust the forge: anonymous clones, code search and streams are rate-limited and capped #2948

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/2920-strangers-cannot-exhaust-the-forge-anony → main 4 commits fork 6 threads · 6 unresolved

  • Merged. torque/2920-strangers-cannot-exhaust-the-forge-anony was merged into main as 6aac8bb.

Review

Sign in to review this change.
20 files changed +1103 −48
docs/ARCHITECTURE.md +14
@@ -383,6 +383,20 @@ Paid-for lessons from open-platform-ts, designed in structurally:
383383 answered. A body no route read, of no declared length, is treated the
384384 same. A cancelled read is never used: it leaves the rest of the body on
385385 the connection to be taken for the next request.
386 7. No stranger can spend the forge (`src/http/strangers.ts`). A request with
387 no session and no token is counted per client address (an IPv6 /64) and
388 for every stranger together, at each door that costs: API reads (600 a
389 minute per address, 6000 for all), clones and fetches (60 a minute, at
390 most 4 in flight per address and half the transfer pool for all, killed
391 after 300 s, and a negotiation that goes 30 s without a byte ends), code
392 search (10 a minute, one stranger's at a time, so half the plumbing pool
393 stays the forge's — the page, the API and the terminal's guest palette,
394 which is counted under its SSH peer address, alike), event streams (8 open per address, 256 for all

Nit: this line runs well past the ~76-column wrap the rest of the list uses (the terminal-palette clause was added without rewrapping). Rewrap it the next time this file is touched.

395 strangers, 4096 for anyone) and spawn plans (10 a minute). Past a limit
396 the answer is a 429 with `Retry-After`. The audit card and the sitemap,
397 which anyone may ask for, are made at most once a minute
398 (`src/kernel/fresh.ts`). `/healthz` is never limited: refusing a probe
399 turns load into a restart.
386400
387401 ## Two processes at once
388402
docs/THREATS.md +6
@@ -106,6 +106,12 @@ Wants: accounts, spam, resources, or a way to exhaust the forge.
106106 Test: `test/fuzz-parsers.test.ts`
107107 - Anonymous spawn is rate limited per client address.
108108 Test: `test/spawn-anon.test.ts`
109 - Every door a stranger can reach that costs (API reads, clones, code
110 search on the page, the API and the terminal's guest palette, event
111 streams, spawn plans) is limited per address and for every stranger
112 together (`src/http/strangers.ts`, docs/ARCHITECTURE.md, concurrency
113 invariant 7); a signed-in push keeps its share of the pool.
114 Test: `test/strangers.test.ts`
109115 - The client address is the socket's peer unless that peer is a configured
110116 trusted proxy; `X-Forwarded-For` is read from the right, past trusted hops.
111117 Test: `test/client-address.test.ts`
src/actions/search.ts +7 −1
@@ -51,6 +51,12 @@ export interface SearchInput {
5151 limit?: number;
5252 /** Also ask each connected origin's own issue search. @default false */
5353 live?: boolean;
54 /**
55 * Grep the code when `kind` asks for it. False when a stranger's code
56 * search was refused (`http/strangers.ts`): the rest still answers.
57 * @default true
58 */
59 withCode?: boolean;
5460 }
5561
5662 /**
@@ -113,7 +119,7 @@ export async function search(
113119 issues.push(...(await liveIssues(app, actor, q, limit)));
114120 }
115121
116 const code = wants("code")
122 const code = wants("code") && input.withCode !== false

withCode is honoured here, but the terminal's guest search (src/tui/forge.ts:538, a guest is any unknown SSH key) never passes it and never takes strangerCodeSearch. So a stranger over SSH can grep code with no limit at all. Gate it the same way as the page, keyed on identity.remoteAddress.

117123 ? await grepRepos(
118124 app.reposRoot,
119125 visible,
src/git/smart-http.ts +36 −5
@@ -35,8 +35,19 @@ export interface SmartHttpOptions {
3535 env?: Record<string, string>;
3636 /** Kill a wedged transfer after this long. @default 600_000 */
3737 timeoutMs?: number;
38 /**
39 * End a fetch whose request body stops arriving for this long: a client
40 * that opens a negotiation and trickles it holds a transfer permit the
41 * whole time. Upload-pack only; a push's pack may pause. @default 30_000
42 */
43 idleMs?: number;
44 /** Given back when the transfer ends, however it ends (a stranger's share). */
45 release?: () => void;
3846 }
3947
48 /** How long a fetch's negotiation may go without a byte before it ends. */
49 export const FETCH_IDLE_MS = 30_000;
50
4051 /**
4152 * GET /:owner/:name.git/info/refs?service=… — the ref advertisement.
4253 * The `# service=` preamble is required for smart clients on protocol v0/v1
@@ -77,7 +88,9 @@ export async function serviceRpc(
7788 options: SmartHttpOptions = {},
7889 ): Promise<Response> {
7990 const proc = await spawnService(repoDir, service, request, options, []);
80 feedStdin(proc, service, request).catch(() => proc.kill("SIGKILL"));
91 feedStdin(proc, service, request, options.idleMs ?? FETCH_IDLE_MS).catch(() =>
92 proc.kill("SIGKILL"),
93 );
8194
8295 return new Response(proc.stdout, {
8396 headers: {
@@ -108,7 +121,11 @@ async function spawnService(
108121 options: SmartHttpOptions,
109122 extraArgs: readonly string[],
110123 ) {
111 const release = await transferGate.acquire();
124 const permit = await transferGate.acquire();
125 const release = (): void => {
126 permit();
127 options.release?.();
128 };
112129 // "git-upload-pack" the service is "upload-pack" the subcommand.
113130 const subcommand = service.slice(4);
114131
@@ -177,6 +194,7 @@ async function feedStdin(
177194 proc: Bun.Subprocess<"pipe", "pipe", "ignore">,
178195 service: GitService,
179196 request: Request,
197 idleMs: number,
180198 ): Promise<void> {
181199 const stdin = proc.stdin;
182200 if (request.body === null) {
@@ -184,13 +202,23 @@ async function feedStdin(
184202 return;
185203 }
186204 let gone = false;
187 void proc.exited.then(() => {
188 gone = true;
189 });
190205 const stop = (): void => {
191206 closeNow(request);
192207 proc.kill("SIGKILL");
193208 };
209 // A fetch's negotiation is a few kilobytes sent at once; one that stops
210 // arriving is a client holding a permit open, and it ends.
211 let idle: ReturnType<typeof setTimeout> | undefined;
212 const poke = (): void => {
213 if (service !== "git-upload-pack") return;
214 clearTimeout(idle);
215 idle = setTimeout(stop, idleMs);
216 };
217 poke();
218 void proc.exited.then(() => {
219 gone = true;
220 clearTimeout(idle);
221 });
194222 const write = async (chunk: Uint8Array): Promise<void> => {
195223 const wrote: unknown = stdin.write(chunk);
196224 if (wrote instanceof Promise) await wrote;
@@ -211,11 +239,14 @@ async function feedStdin(
211239 const max = service === "git-receive-pack" ? PUSH_MAX : FETCH_MAX;
212240 for await (const chunk of counted(request.body, max, stop)) {
213241 if (gone) break;
242 poke();
214243 await write(chunk);
215244 }
216245 }
246 clearTimeout(idle);
217247 if (!gone) await stdin.end();
218248 } catch {
249 clearTimeout(idle);
219250 // Git went away mid-write: the body has nowhere to go, and its answer
220251 // (or its absence) is the response's business.
221252 proc.kill("SIGKILL");
src/http/api.ts +32 −11
@@ -217,7 +217,8 @@ import {
217217 canWrite,
218218 getRepoById,
219219 } from "../core/repos.ts";
220 import { searchKind } from "../core/search.ts";
220 import { cleanQuery, searchKind } from "../core/search.ts";
221 import { strangerCodeSearch } from "./strangers.ts";
221222 import {
222223 isReviewState,
223224 listReviewComments,
@@ -2146,15 +2147,35 @@ export function apiRoutes(router: Router<Handler>): void {
21462147 // caller has connected, conversations here, and the code itself. `live=1`
21472148 // additionally asks each origin's own issue search, inside one budget.
21482149
2150 // A stranger's search that greps code (`kind` code or all) takes a slot
2151 // of its own first (`strangers.ts`): `git grep` over fifty repositories
2152 // is not a request to answer as fast as anyone can send it.
21492153 router.add("GET", "/api/v1/search", async (ctx) => {
21502154 const q = ctx.url.searchParams;
2151 const found = await search(ctx.app, ctx.auth.user, {
2152 q: q.get("q") ?? "",
2153 kind: searchKind(q.get("kind")),
2154 ...(q.has("limit") ? { limit: Number(q.get("limit")) } : {}),
2155 live: q.get("live") === "1" || q.get("live") === "true",
2156 });
2157 return json(found);
2155 const kind = searchKind(q.get("kind"));
2156 const query = q.get("q") ?? "";
2157 const greps =
2158 ctx.auth.user === undefined &&
2159 (kind === "code" || kind === "all") &&
2160 cleanQuery(query) !== "";
2161 const held = greps ? strangerCodeSearch(ctx.app, ctx.request) : undefined;
2162 if (held !== undefined && !held.allowed) {
2163 const refused = apiError("rate_limited", 429, `${held.why}; sign in, or retry in ${held.retryAfter}s`);
2164 refused.headers.set("retry-after", String(held.retryAfter));
2165 return refused;
2166 }
2167 try {
2168 return json(
2169 await search(ctx.app, ctx.auth.user, {
2170 q: query,
2171 kind,
2172 ...(q.has("limit") ? { limit: Number(q.get("limit")) } : {}),
2173 live: q.get("live") === "1" || q.get("live") === "true",
2174 }),
2175 );
2176 } finally {
2177 held?.release();
2178 }
21582179 });
21592180
21602181 // ── repositories ─────────────────────────────────────────────────────
@@ -4558,7 +4579,7 @@ export function apiRoutes(router: Router<Handler>): void {
45584579 ctx.params.job!,
45594580 );
45604581 if (!found.ok) return respondAccess(found.error, 404);
4561 return eventStream(ctx.request, (emit, close) =>
4582 return eventStream(ctx.app, ctx.request, (emit, close) =>
45624583 subscribeJob(found.value.id, (job) => {
45634584 emit(`mirror.${job.stage}`, jobJson(ctx, job), job.id);
45644585 if (job.stage === "done" || job.stage === "failed") close();
@@ -4984,7 +5005,7 @@ export function apiRoutes(router: Router<Handler>): void {
49845005 router.add("GET", "/api/v1/migrate/:job/stream", (ctx) => {
49855006 const found = migrationJobFor(ctx.auth.user, ctx.params.job!);
49865007 if (!found.ok) return respondAccess(found.error, 404);
4987 return eventStream(ctx.request, (emit, close) =>
5008 return eventStream(ctx.app, ctx.request, (emit, close) =>
49885009 subscribeJob(found.value.id, (job) => {
49895010 emit(`migrate.${job.stage}`, migrationJson(ctx, job), job.id);
49905011 if (job.stage === "done" || job.stage === "failed") close();
@@ -5517,7 +5538,7 @@ export function apiRoutes(router: Router<Handler>): void {
55175538 (status.state === "running" || status.state === "deploying")
55185539 );
55195540 };
5520 return eventStream(ctx.request, (emit, close) => {
5541 return eventStream(ctx.app, ctx.request, (emit, close) => {
55215542 const controller = new AbortController();
55225543 let index = 0;
55235544 const send = (line: string): void => {
src/http/limits.ts +3
@@ -48,6 +48,9 @@ export const noteSignedIn = (request: Request): void => {
4848 signedIn.add(request);
4949 };
5050
51 /** Whether the request carries a signed-in person or a token. */
52 export const isSignedIn = (request: Request): boolean => signedIn.has(request);
53
5154 /** The most a shared reader keeps of this request's body. */
5255 export const bodyCapFor = (request: Request): number =>
5356 signedIn.has(request) ? BODY_MAX : STRANGER_MAX;
src/http/server.ts +69 −9
@@ -46,6 +46,7 @@ import {
4646 armClose,
4747 BODY_MAX,
4848 carryNotes,
49 closeAfter,
4950 mustClose,
5051 noteSignedIn,
5152 readBounded,
@@ -82,7 +83,14 @@ import {
8283 isGitService,
8384 serviceRpc,
8485 type GitService,
86 type SmartHttpOptions,
8587 } from "../git/smart-http.ts";
88 import {
89 strangerLimits,
90 strangerTransfer,
91 takeDoor,
92 tooManyStrangers,
93 } from "./strangers.ts";
8694 import { timingSafeEqual } from "node:crypto";
8795 import {
8896 forwardedGitEnv,
@@ -997,12 +1005,16 @@ export function buildRouter(): Router<Handler> {
9971005 if (!isGitService(service))
9981006 return new Response("smart http only", { status: 400 });
9991007 return withGitAccess(ctx, service, (dir) =>
1000 advertiseRefs(dir, service, ctx.request),
1008 asStranger(ctx, (options) =>
1009 advertiseRefs(dir, service, ctx.request, options),
1010 ),
10011011 );
10021012 });
10031013 router.add("POST", "/:owner/:name.git/git-upload-pack", (ctx) =>
10041014 withGitAccess(ctx, "git-upload-pack", (dir) =>
1005 serviceRpc(dir, "git-upload-pack", ctx.request),
1015 asStranger(ctx, (options) =>
1016 serviceRpc(dir, "git-upload-pack", ctx.request, options),
1017 ),
10061018 ),
10071019 );
10081020 router.add("POST", "/:owner/:name.git/git-receive-pack", (ctx) =>
@@ -1395,6 +1407,39 @@ export function knownPatterns(): string[] {
13951407 return (known ??= [...buildRouter().patterns(), ...buildApiRouter().patterns()]);
13961408 }
13971409
1410 /**
1411 * A transfer someone signed in makes runs as it always has; a stranger's
1412 * takes a share of the transfer pool first (`strangers.ts`) — refused with
1413 * a 429 when its address or every stranger together has none left — and
1414 * runs on a shorter clock. Called once access is decided, so a private
1415 * repository still answers a stranger with the credential challenge.
1416 */
1417 async function asStranger(
1418 ctx: Ctx,
1419 run: (options: SmartHttpOptions) => Promise<Response>,
1420 ): Promise<Response> {
1421 if (ctx.auth.user !== undefined) return run({});
1422 const held = strangerTransfer(ctx.app, ctx.request);
1423 if (!held.allowed) {
1424 // Refused before a byte of the body is wanted: nothing more is read of
1425 // it, and the connection ends once the 429 is out (`http/limits.ts`).
1426 closeAfter(ctx.request);
1427 void ctx.request.body?.cancel().catch(() => {});
1428 return tooManyStrangers(held);
1429 }
1430 const limits = strangerLimits(ctx.app);
1431 try {
1432 return await run({
1433 release: held.release,
1434 timeoutMs: limits.transferTimeoutMs,
1435 idleMs: limits.transferIdleMs,
1436 });
1437 } catch (error) {
1438 held.release();
1439 throw error;
1440 }
1441 }
1442
13981443 async function withGitAccess(
13991444 ctx: Ctx,
14001445 service: GitService,
@@ -2307,9 +2352,11 @@ async function answer(
23072352 request: Request,
23082353 ): Promise<Response> {
23092354 const response = await answerGet(app, router, apiRouter, limiter, request);
2310 return request.method === "HEAD"
2311 ? new Response(null, { status: response.status, headers: response.headers })
2312 : response;
2355 if (request.method !== "HEAD") return response;
2356 // The body nobody will read is cancelled, so a stream behind it (an
2357 // event stream's slot, a git process) ends now rather than never.
2358 void response.body?.cancel().catch(() => {});

No test covers this. Add a stranger HEAD to /events, then check that heldNow(app, "stream:*") returns to 0. Without this line the test should fail.

2359 return new Response(null, { status: response.status, headers: response.headers });
23132360 }
23142361
23152362 async function answerGet(
@@ -2387,13 +2434,26 @@ async function answerGet(
23872434 // The two things standing between one runaway agent and everybody
23882435 // else's latency, applied before a handler runs and after auth is
23892436 // known — a limit needs an identity, and a replay needs a credential.
2437 // A stranger has no token to count against: its address does, and every
2438 // stranger together (`strangers.ts`) — on the API, which is what a
2439 // script reads; the doors that cost more keep tighter limits of their own.
23902440 const credential = tokenCredential(request, auth);
23912441 const verdict =
2392 credential === undefined
2393 ? undefined
2394 : limiter.take(credential, auth.scope ?? "ro");
2442 credential !== undefined
2443 ? limiter.take(credential, auth.scope ?? "ro")
2444 : auth.user === undefined && isApiPath && limiter.enabled
2445 ? takeDoor(app, "api", request)
2446 : undefined;
23952447 if (verdict !== undefined && !verdict.allowed) {
2396 return withRateHeaders(tooManyRequests(verdict), verdict);
2448 const refusal =
2449 credential === undefined
2450 ? apiError(
2451 "rate_limited",
2452 429,
2453 `too many requests without a token: ${verdict.limit} per minute — sign in, or retry in ${verdict.retryAfter}s`,
2454 )
2455 : tooManyRequests(verdict);
2456 return withRateHeaders(refusal, verdict);
23972457 }
23982458
23992459 const themeCookie = cookieValue(request, "forge_theme") ?? "";
src/http/sse.ts +13 −2
@@ -15,6 +15,8 @@
1515 */
1616
1717 import { holdForCaller } from "./caller.ts";
18 import { isSignedIn } from "./limits.ts";
19 import { holdStream, tooManyStrangers } from "./strangers.ts";
1820 import type { App } from "../app.ts";
1921 import type { User } from "../core/users.ts";
2022 import { listEvents, listEventsVisibleTo } from "../core/events.ts";
@@ -82,11 +84,19 @@ export type Emit = (event: string, data: unknown, id?: string) => void;
8284 * Open a stream. `register` is called once with the emitter and a `close`;
8385 * it returns its own unsubscribe, which runs when the client goes away, when
8486 * `close` is called, or when a write fails — whichever happens first.
87 *
88 * Every stream is counted on `app` while it is open (`strangers.ts`): a
89 * stranger's against its address and every stranger's together, anyone's
90 * against the forge's whole budget. Past either, the answer is a 429 and
91 * nothing is registered.
8592 */
8693 export function eventStream(
94 app: App,
8795 request: Request,
8896 register: (emit: Emit, close: () => void, ready: () => boolean) => () => void,
8997 ): Response {
98 const held = holdStream(app, request, !isSignedIn(request));
99 if (!held.allowed) return tooManyStrangers(held);
90100 const encoder = new TextEncoder();
91101 let cleanup = () => {};
92102
@@ -132,6 +142,7 @@ export function eventStream(
132142 cleanup = () => {
133143 if (!open) return;
134144 open = false;
145 held.release();
135146 release();
136147 unsubscribe();
137148 clearInterval(heartbeat);
@@ -218,7 +229,7 @@ export function viewerStream(
218229 // admins', a person's own choices are theirs (`core/event_audience.ts`).
219230 const meant = audienceFilter(app.db, viewer);
220231 const after = resumeFrom(request);
221 return eventStream(request, (emit, _close, ready) => {
232 return eventStream(app, request, (emit, _close, ready) => {
222233 if (after !== undefined) {
223234 const missed = listEventsVisibleTo(app.db, viewer, {
224235 afterId: after,
@@ -269,7 +280,7 @@ export function sseStream(
269280 ): Response {
270281 const meant = audienceFilter(app.db, viewer);
271282 const after = resumeFrom(request);
272 return eventStream(request, (emit) => {
283 return eventStream(app, request, (emit) => {
273284 // The event id doubles as the SSE id, so a reconnecting client resumes
274285 // from where it stopped: what it missed first, oldest first, then live.
275286 if (after !== undefined)
src/http/strangers.ts added +310
@@ -0,0 +1,310 @@
1 /**
2 * What a stranger — a request with no session and no token — may spend.
3 *
4 * A signed-in caller is limited by its token (`core/ratelimit.ts`) and is
5 * someone the forge can suspend. A stranger is neither, and the doors open
6 * to everyone are the expensive ones: a clone holds a transfer permit for
7 * minutes, a code search runs `git grep` over fifty repositories, a stream
8 * holds a connection open for as long as the client likes, a spawn plan
9 * walks what a catalog app carries. Each door here has two limits, one per
10 * client address (`peer.ts`'s bucket, an IPv6 /64) and one for the whole
11 * forge, so a thousand addresses cannot do together what one may not —
12 * and the forge-wide one is what keeps a share of every pool for pushes,
13 * page renders and agents.
14 *
15 * Rates are a sliding minute (`RateLimiter`); what is held — a transfer, a
16 * search, a stream — is counted while it is held and given back on every
17 * path out. One set per forge, so a test's forge never throttles another's.
18 */
19
20 import type { App } from "../app.ts";
21 import { RateLimiter, type Verdict } from "../core/ratelimit.ts";
22 import { transferGate } from "../git/env.ts";
23 import { bucketOf, clientBucket } from "./peer.ts";
24
25 /** A door a stranger is counted at, each with its own minute. */
26 export type Door = "api" | "git" | "code" | "plan";
27
28 export interface StrangerLimits {
29 /** Requests per minute through each door, per client address. */
30 readonly perAddress: Readonly<Record<Door, number>>;
31 /** Requests per minute through each door, every stranger together. */
32 readonly forge: Readonly<Record<Door, number>>;
33 /** Pack transfers one address may hold at once. */
34 readonly transfersPerAddress: number;
35 /**
36 * The share of the transfer pool strangers may hold together: the rest is
37 * kept for pushes and signed-in clones. At least one permit.
38 */
39 readonly transferShare: number;
40 /** A stranger's transfer is killed after this long (a signed-in one: 600 s). */
41 readonly transferTimeoutMs: number;
42 /** A fetch's request body that stops arriving for this long ends the fetch. */
43 readonly transferIdleMs: number;
44 /**
45 * Code searches strangers may run at once, every one together. One search
46 * greps four repositories at a time (`core/search.ts`), so one is half the
47 * eight-permit plumbing pool and the other half stays the forge's own.
48 */
49 readonly codeAtOnce: number;
50 /** Event streams one address may hold open. */
51 readonly streamsPerAddress: number;
52 /** Event streams strangers may hold open, every one together. */
53 readonly strangerStreams: number;
54 /** Event streams anyone may hold open on this forge. */
55 readonly allStreams: number;
56 }
57
58 export const STRANGER_LIMITS: StrangerLimits = {
59 perAddress: { api: 600, git: 60, code: 10, plan: 10 },
60 forge: { api: 6000, git: 600, code: 60, plan: 60 },
61 transfersPerAddress: 4,
62 transferShare: 0.5,
63 transferTimeoutMs: 300_000,
64 transferIdleMs: 30_000,
65 codeAtOnce: 1,
66 streamsPerAddress: 8,
67 strangerStreams: 256,
68 allStreams: 4096,
69 };
70
71 /** A door refused: what to say, and when to come back (seconds). */
72 export interface Refused {
73 readonly allowed: false;
74 readonly retryAfter: number;
75 readonly why: string;
76 }
77
78 /** Something held, given back by `release` (idempotent). */
79 export interface Held {
80 readonly allowed: true;
81 readonly release: () => void;
82 }
83
84 interface State {
85 limits: StrangerLimits;
86 readonly perAddress: Map<Door, RateLimiter>;
87 readonly forge: Map<Door, RateLimiter>;
88 /** What is held right now, by key: "transfer:<bucket>", "code", "stream:*". */
89 readonly held: Map<string, number>;
90 }
91
92 const states = new WeakMap<App, State>();
93
94 function stateOf(app: App): State {
95 let state = states.get(app);
96 if (state === undefined) {
97 state = {
98 limits: STRANGER_LIMITS,
99 perAddress: new Map(),
100 forge: new Map(),
101 held: new Map(),
102 };
103 states.set(app, state);
104 }
105 return state;
106 }
107
108 const minute = (perMinute: number): RateLimiter =>
109 new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true });
110
111 function limiter(
112 map: Map<Door, RateLimiter>,
113 door: Door,
114 perMinute: number,
115 ): RateLimiter {
116 let found = map.get(door);
117 if (found === undefined) {
118 found = minute(perMinute);
119 map.set(door, found);
120 }
121 return found;
122 }
123
124 /** This forge's limits for strangers. */
125 export const strangerLimits = (app: App): StrangerLimits => stateOf(app).limits;
126
127 /**
128 * Test seam and operator lever: new limits for this forge's strangers.
129 * Counted minutes start over.
130 */
131 export function setStrangerLimits(
132 app: App,
133 change: Partial<StrangerLimits>,
134 ): void {
135 const state = stateOf(app);
136 state.limits = { ...state.limits, ...change };
137 state.perAddress.clear();
138 state.forge.clear();
139 }
140
141 /**
142 * Count one stranger's request through `door`: refused when its address or
143 * every stranger together has spent the minute. A refusal is not counted
144 * (`RateLimiter.take`), and one refused forge-wide gives back the address's
145 * count, so a closed door does not push anyone's window forward.
146 */
147 export const takeDoor = (app: App, door: Door, request: Request): Verdict =>
148 takeDoorAt(app, door, clientBucket(request));
149
150 /**
151 * `takeDoor` for a client that is not an HTTP request — a terminal guest —
152 * keyed on its address's bucket (`peer.ts`'s `bucketOf`, an IPv6 /64).
153 */
154 export function takeDoorAt(app: App, door: Door, bucket: string): Verdict {
155 const state = stateOf(app);
156 const mine = limiter(state.perAddress, door, state.limits.perAddress[door]);
157 const own = mine.take(bucket, "ro");
158 if (!own.allowed) return own;
159 const all = limiter(state.forge, door, state.limits.forge[door]).take("*", "ro");
160 if (!all.allowed) {
161 mine.refund(bucket);
162 return all;
163 }
164 return own;
165 }
166
167 /** Take one of `key` if fewer than `max` are held; the release gives it back. */
168 function hold(state: State, key: string, max: number): (() => void) | undefined {
169 const now = state.held.get(key) ?? 0;
170 if (now >= max) return undefined;
171 state.held.set(key, now + 1);
172 let released = false;
173 return () => {
174 if (released) return;
175 released = true;
176 const left = (state.held.get(key) ?? 1) - 1;
177 if (left <= 0) state.held.delete(key);
178 else state.held.set(key, left);
179 };
180 }
181
182 /** Take every hold in `wants`, or none of them. */
183 function holdAll(
184 state: State,
185 wants: readonly (readonly [string, number])[],
186 ): (() => void) | undefined {
187 const taken: (() => void)[] = [];
188 for (const [key, max] of wants) {
189 const release = hold(state, key, max);
190 if (release === undefined) {
191 for (const give of taken) give();
192 return undefined;
193 }
194 taken.push(release);
195 }
196 return () => {
197 for (const give of taken) give();
198 };
199 }
200
201 const refused = (why: string, retryAfter: number): Refused => ({
202 allowed: false,
203 retryAfter,
204 why,
205 });
206
207 /**
208 * A stranger's pack transfer (a clone's ref advertisement or its fetch):
209 * counted against the minute, then held — at most `transfersPerAddress` per
210 * address, and together at most `transferShare` of the transfer pool, so a
211 * crowd of anonymous clones never stands between a push and its permit.
212 */
213 export function strangerTransfer(app: App, request: Request): Held | Refused {
214 const verdict = takeDoor(app, "git", request);
215 if (!verdict.allowed)
216 return refused(
217 "too many anonymous git requests; sign in or retry later",
218 verdict.retryAfter,
219 );
220 const state = stateOf(app);
221 const share = Math.max(
222 1,
223 Math.floor(transferGate.limit * state.limits.transferShare),
224 );
225 const release = holdAll(state, [
226 [`transfer:${clientBucket(request)}`, state.limits.transfersPerAddress],
227 ["transfer", share],
228 ]);
229 if (release === undefined)
230 return refused(
231 "too many anonymous git transfers at once; sign in or retry shortly",
232 5,
233 );
234 return { allowed: true, release };
235 }
236
237 /**
238 * A stranger's code search: counted against the minute, then held — at most
239 * `codeAtOnce` searches by strangers at a time, the whole forge together.
240 */
241 export const strangerCodeSearch = (app: App, request: Request): Held | Refused =>
242 codeSearchAt(app, clientBucket(request));
243
244 /**
245 * `strangerCodeSearch` for a terminal guest (any SSH key the forge does not
246 * know), counted under its peer address: the palette's search greps code too.
247 */
248 export const guestCodeSearch = (app: App, remoteAddress: string): Held | Refused =>
249 codeSearchAt(app, bucketOf(remoteAddress));
250
251 function codeSearchAt(app: App, bucket: string): Held | Refused {
252 const verdict = takeDoorAt(app, "code", bucket);
253 if (!verdict.allowed)
254 return refused(
255 "too many anonymous code searches; sign in or retry later",
256 verdict.retryAfter,
257 );
258 const state = stateOf(app);
259 const release = hold(state, "code", state.limits.codeAtOnce);
260 if (release === undefined)
261 return refused("code search is busy for visitors who are not signed in", 2);
262 return { allowed: true, release };
263 }
264
265 /**
266 * An event stream: every one counts toward `allStreams`; a stranger's also
267 * toward its address and toward every stranger's together.
268 */
269 export function holdStream(
270 app: App,
271 request: Request,
272 stranger: boolean,
273 ): Held | Refused {
274 const state = stateOf(app);
275 const { limits } = state;
276 const release = holdAll(
277 state,
278 stranger
279 ? [
280 ["stream:*", limits.allStreams],
281 ["stream:strangers", limits.strangerStreams],
282 [`stream:${clientBucket(request)}`, limits.streamsPerAddress],
283 ]
284 : [["stream:*", limits.allStreams]],
285 );
286 if (release === undefined)
287 return refused(
288 stranger
289 ? "too many open streams for visitors who are not signed in"
290 : "too many open streams",
291 30,
292 );
293 return { allowed: true, release };
294 }
295
296 /** How many of `key` are held now — for tests. */
297 export const heldNow = (app: App, key: string): number =>
298 stateOf(app).held.get(key) ?? 0;
299
300 /** The plain 429 every refused door answers with. */
301 export function tooManyStrangers(refusal: { retryAfter: number; why: string }): Response {
302 return new Response(`${refusal.why}\n`, {
303 status: 429,
304 headers: {
305 "content-type": "text/plain; charset=utf-8",
306 "retry-after": String(Math.max(1, refusal.retryAfter)),
307 "cache-control": "no-store",
308 },
309 });
310 }
src/kernel/fresh.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * A value kept for a short while: the first caller makes it, everyone
3 * after it inside `ttlMs` is handed the same one — a promise included, so
4 * a burst of callers while it is being made waits on one piece of work
5 * instead of starting its own. A promise that rejects is forgotten at
6 * once, so a failure is never what the next caller is handed.
7 *
8 * For an answer anyone may ask for and nobody needs to the second (a
9 * public card, a sitemap): what a crowd of strangers can make the forge
10 * do is then one piece of work per key per `ttlMs`, however many ask.
11 * Keys are bounded (`maxKeys`, the oldest dropped first), so a caller who
12 * varies the key cannot grow the map.
13 */
14 export class Fresh<T> {
15 private readonly kept = new Map<string, { value: T; until: number }>();
16
17 constructor(
18 private readonly ttlMs: number,
19 private readonly maxKeys = 16,
20 private readonly now: () => number = Date.now,
21 ) {}
22
23 get(key: string, make: () => T): T {
24 const at = this.now();
25 const hit = this.kept.get(key);
26 if (hit !== undefined && hit.until > at) return hit.value;
27 this.kept.delete(key);
28 const value = make();
29 if (value instanceof Promise)
30 value.catch(() => {
31 if (this.kept.get(key)?.value === value) this.kept.delete(key);
32 });
33 this.kept.set(key, { value, until: at + this.ttlMs });
34 while (this.kept.size > this.maxKeys) {
35 const oldest = this.kept.keys().next().value;
36 if (oldest === undefined) break;
37 this.kept.delete(oldest);
38 }
39 return value;
40 }
41
42 /** Forget everything kept. */
43 clear(): void {
44 this.kept.clear();
45 }
46 }
src/spawn/door.ts +14
@@ -15,6 +15,7 @@ import { kindOfToken, parseApiBase } from "../core/subscriptions.ts";
1515 import type { User } from "../core/users.ts";
1616 import { repoDir } from "../git/repos.ts";
1717 import type { Ctx } from "../http/context.ts";
18 import { takeDoor } from "../http/strangers.ts";
1819 import {
1920 childPublicUrl,
2021 hostedPlacement,
@@ -154,6 +155,19 @@ export async function spawnDoor(
154155 ): Promise<Door> {
155156 const anon = user === undefined;
156157 if (!anon && user.kind !== "user") return spawnWords({ kind: "forbidden" });
158 // A plan is free of children but not of work — it reads every catalog
159 // app it names — so a stranger's are counted per address and per forge.
160 if (anon) {
161 const verdict = takeDoor(ctx.app, "plan", ctx.request);
162 if (!verdict.allowed)
163 return {
164 status: 429,
165 body: {
166 error: "rate_limited",
167 message: `too many plans without signing in: ${verdict.limit} a minute; try again in ${verdict.retryAfter}s`,
168 },
169 };
170 }
157171 if (raw["dryRun"] !== undefined && typeof raw["dryRun"] !== "boolean")
158172 return invalid("dryRun", "must be true or false");
159173 const dry = raw["dryRun"] !== false;
src/tui/forge.ts +30 −6
@@ -10,6 +10,8 @@
1010
1111 import type { App as Forge } from "../app.ts";
1212 import { search } from "../actions/search.ts";
13 import { cleanQuery } from "../core/search.ts";
14 import { guestCodeSearch } from "../http/strangers.ts";
1315 import { sentinelSummary } from "../crew/sentinel.ts";
1416 import { canRead, getRepo, type Repo } from "../core/repos.ts";
1517 import { sourceFor } from "../view/source.ts";
@@ -533,13 +535,26 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
533535 * first, mirror if you mean it (`view/source.ts`). A repo somebody else's
534536 * origin holds is `not_found` for you, and `sourceFor` is what says so.
535537 */
536 function forgeSearch(session: Session): (ctx: Ctx) => PaletteSearch {
538 export function forgeSearch(session: Session): (ctx: Ctx) => PaletteSearch {
537539 return (ctx) => async (query, live, signal) => {
538 const found = await search(session.app, session.user, {
539 q: query,
540 limit: 8,
541 live,
542 });
540 // A guest is a stranger with a key: its search greps code under the same
541 // limits as the page's and the API's (`http/strangers.ts`), counted
542 // under its peer address. Refused, everything but the code is searched.
543 const greps = session.user === undefined && cleanQuery(query) !== "";
544 const held = greps
545 ? guestCodeSearch(session.app, session.identity.remoteAddress)
546 : undefined;
547 let found;
548 try {
549 found = await search(session.app, session.user, {
550 q: query,
551 limit: 8,
552 live,
553 withCode: held === undefined || held.allowed,
554 });
555 } finally {
556 if (held?.allowed) held.release();
557 }
543558 if (signal.aborted) return [];
544559 const items: PaletteItem[] = [];
545560 const set = ctx.icons;
@@ -655,6 +670,15 @@ function forgeSearch(session: Session): (ctx: Ctx) => PaletteSearch {
655670 },
656671 });
657672 }
673 if (held !== undefined && !held.allowed)
674 items.push({
675 id: "code:not-searched",
676 section: "Code",
677 mark: G.ring,
678 title: "Code was not searched",
679 hint: `${held.why}; retry in ${held.retryAfter}s`,
680 run: () => ctx.toast("Sign in to search code without waiting.", "secondary"),
681 });
658682 return items;
659683 };
660684 }
src/web/pages/og.ts +17 −1
@@ -18,6 +18,7 @@
1818
1919 import { createHash } from "node:crypto";
2020 import { join } from "node:path";
21 import { Fresh } from "../../kernel/fresh.ts";
2122 import type { App } from "../../app.ts";
2223 import type { Run } from "../../core/runs.ts";
2324 import { getUserById } from "../../core/users.ts";
@@ -351,7 +352,7 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput |
351352 if (kind === "issue" || kind === "pull") return threadInput(ctx, kind, ref);
352353 if (kind === "run") return runInput(ctx, ref);
353354 if (kind === "map") return mapCardInput(ctx, ref);
354 if (kind === "audit") return ref === "" ? auditCardInput(ctx) : undefined;
355 if (kind === "audit") return ref === "" ? auditCards(ctx.app).get("", () => auditCardInput(ctx)) : undefined;

The audit card cache has no test. Fetch /og/audit.svg, add a public app, fetch again, and expect the same body inside the TTL (the same shape as the sitemap test).

355356 if (kind === "site") return ref === "" ? SITE_CARD : undefined;
356357 if (kind === "repo") return repoCard(ctx, ref);
357358 if (!isSlug(ref)) return undefined;
@@ -374,6 +375,21 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput |
374375 };
375376 }
376377
378 /**
379 * The audit card reads every public app's repository with git; anyone may
380 * ask for it, so it is made once per AUDIT_CARD_MS however many do.
381 */
382 const AUDIT_CARD_MS = 45_000;
383 const auditCache = new WeakMap<App, Fresh<Promise<CardInput>>>();
384 function auditCards(app: App): Fresh<Promise<CardInput>> {
385 let kept = auditCache.get(app);
386 if (kept === undefined) {
387 kept = new Fresh(AUDIT_CARD_MS, 1);
388 auditCache.set(app, kept);
389 }
390 return kept;
391 }
392
377393 const caches = new WeakMap<App, PngCache>();
378394
379395 function cacheFor(app: App): PngCache {
src/web/pages/search.ts +37 −3
@@ -10,6 +10,7 @@
1010 * that was never meant to be confirmed.
1111 */
1212
13 import { strangerCodeSearch } from "../../http/strangers.ts";
1314 import { search } from "../../actions/search.ts";
1415 import { appsRuntime, envsFor } from "../../apps/runtime.ts";
1516 import type { AppRecord } from "../../core/apps.ts";
@@ -299,8 +300,32 @@ function section(
299300
300301 /** The same `search()` the API and the palette call, so the sources can
301302 * never disagree between surfaces. */
302 async function find(ctx: Ctx, a: Asked): Promise<SearchResults> {
303 return search(ctx.app, ctx.auth.user, { q: a.query, kind: a.kind, live: a.live });
303 async function find(
304 ctx: Ctx,
305 a: Asked,
306 ): Promise<{ found: SearchResults; busy?: number }> {
307 // A stranger's code search takes a slot of its own (`http/strangers.ts`);
308 // refused, the page still answers with everything but the code, and says so.
309 // The same test as the API's: a blank or stop-word query greps nothing.
outdated

The API also requires cleanQuery(query) !== "" before it counts a code search. Without that here, an empty or stop-word query on the page spends the stranger's minute and takes the single slot, even though it greps nothing. Use the same condition on both doors.

310 const greps =
311 ctx.auth.user === undefined &&
312 (a.kind === "code" || a.kind === "all") &&
313 cleanQuery(a.query) !== "";
314 const held = greps ? strangerCodeSearch(ctx.app, ctx.request) : undefined;
315 const withCode = held === undefined || held.allowed;
316 try {
317 const found = await search(ctx.app, ctx.auth.user, {
318 q: a.query,
319 kind: a.kind,
320 live: a.live,
321 withCode,
322 });
323 return held !== undefined && !held.allowed
324 ? { found, busy: held.retryAfter }
325 : { found };
326 } finally {
327 if (held?.allowed) held.release();
328 }
304329 }
305330
306331 /**
@@ -360,7 +385,7 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
360385 });
361386 }
362387
363 const found = await find(ctx, a);
388 const { found, busy } = await find(ctx, a);
364389 const seen = appsOf(ctx, ctx.auth.user);
365390 const apps: Apps = {
366391 byId: seen.records,
@@ -425,6 +450,15 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
425450 const body = html`${pageHead(html`Results for “${a.query}”`)} ${form}
426451 <div class="filters">${chips}</div>
427452 ${liveToggle}
453 ${when(
454 busy !== undefined,
455 () =>
456 html`<p class="muted small" data-code-busy>
457 Code was not searched: code search is busy for visitors who are not
458 signed in. <a href="/signin">Sign in</a>, or try again in
459 ${String(busy)} s.
460 </p>`,
461 )}
428462 ${WORDS
429463 .filter(
430464 ([kind]) => kind !== "all" && (a.kind === "all" || a.kind === kind),
src/web/pages/system.ts +1 −1
@@ -134,7 +134,7 @@ export function systemStream(ctx: Ctx): Response {
134134 if (!ctx.auth.user) return signInFirst(ctx);
135135 if (telemetryReader(ctx.app, ctx.auth.user)?.kind !== "owner") return notFoundPage(ctx);
136136 const sampler = live();
137 return eventStream(ctx.request, (emit, _close, ready) => {
137 return eventStream(ctx.app, ctx.request, (emit, _close, ready) => {
138138 emit("sample", sampleFrame(sampler.read()));
139139 return sampler.subscribe((view) => {
140140 if (ready()) emit("sample", sampleFrame(view));
src/web/pages/teams.ts +1 −1
@@ -100,7 +100,7 @@ export const teamsLiveJs = /* js */ `
100100 // GET /settings/teams/stream: the page's regions again, each time a team changes.
101101 export function teamsStream(ctx: Ctx): Response {
102102 if (!isAdminPerson(ctx)) return notFoundPage(ctx);
103 return eventStream(ctx.request, (emit, _close, ready) => {
103 return eventStream(ctx.app, ctx.request, (emit, _close, ready) => {
104104 const send = () => {
105105 const r = regions(ctx);
106106 if (r && ready()) emit("teams", { regions: r });
src/web/standards.ts +30 −7
@@ -24,6 +24,8 @@ import releases from "../core/releases.json" with { type: "json" };
2424 import { markPng, type MarkShape } from "../design/mark.ts";
2525 import { BODY_MAX, TITLE_MAX } from "../core/reports.ts";
2626 import { canRead, getRepo, listReposVisibleTo } from "../core/repos.ts";
27 import type { App } from "../app.ts";
28 import { Fresh } from "../kernel/fresh.ts";
2729 import type { Ctx } from "../http/context.ts";
2830 import { notFound } from "../http/respond.ts";
2931
@@ -138,16 +140,30 @@ function xml(s: string): string {
138140 );
139141 }
140142
143 /**
144 * A sitemap is up to five thousand rows, asked for by anyone: its rows are
145 * read once a minute, however many ask. They are kept without an origin,
146 * under one key, and the origin is put on per answer — so a caller who
147 * varies `Host` (where `FORGE_URL` does not fix the origin) gets their own
148 * origin on the same rows and cannot make the forge read them again.
149 */
150 const SITEMAP_MS = 60_000;
151 const sitemaps = new WeakMap<App, Fresh<readonly string[]>>();
152
141153 /** GET /sitemap.xml — the front page and every public repository. */
142154 export function sitemapXml(ctx: Ctx): Response {
143 const origin = ctx.url.origin;
144 const repos = listReposVisibleTo(ctx.app.db, undefined, { limit: 5000 });
155 let kept = sitemaps.get(ctx.app);
156 if (kept === undefined) {
157 kept = new Fresh(SITEMAP_MS, 1);
158 sitemaps.set(ctx.app, kept);
outdated

The key comes from the request's Host. With 4 keys evicted in insertion order, rotating Host values defeats the cache. Key on the configured public origin, or document why only known hosts get here.

159 }
160 const paths = kept.get("", () => sitemapPaths(ctx.app));
161 // Escaping is per character, so the escaped origin and the escaped path
162 // join into the escaped URL.
163 const origin = xml(ctx.url.origin);
145164 const urls = [
146 `<url><loc>${xml(origin)}/</loc></url>`,
147 ...repos.map(
148 (r) =>
149 `<url><loc>${xml(`${origin}/${r.ownerName}/${r.name}`)}</loc></url>`,
150 ),
165 `<url><loc>${origin}/</loc></url>`,
166 ...paths.map((path) => `<url><loc>${origin}${path}</loc></url>`),
151167 ];
152168 return text(
153169 `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>\n`,
@@ -155,6 +171,13 @@ export function sitemapXml(ctx: Ctx): Response {
155171 );
156172 }
157173
174 /** Every public repository's path, escaped for XML, newest first. */
175 function sitemapPaths(app: App): readonly string[] {
176 return listReposVisibleTo(app.db, undefined, { limit: 5000 }).map((r) =>
177 xml(`/${r.ownerName}/${r.name}`),
178 );
179 }
180
158181 /** GET /manifest.webmanifest — the forge as an app on a home screen. */
159182 export function webManifest(ctx: Ctx): Response {
160183 const name = ctx.app.config.edgeName ? "forge" : "forge";
test/agents.test.ts +1
@@ -673,6 +673,7 @@ describe("backpressure", () => {
673673 let push: ((text: string) => void) | undefined;
674674 const controller = new AbortController();
675675 const response = eventStream(
676 {} as App,
676677 new Request("http://forge/stream", { signal: controller.signal }),
677678 (emit) => {
678679 push = (text) => emit("noise", { text });
test/strangers.test.ts added +435
@@ -0,0 +1,435 @@
1 /**
2 * Strangers cannot exhaust the forge (#2920): every door open to someone
3 * with no session and no token — the API, a clone, a code search, an event
4 * stream, a spawn plan — is limited per address and for every stranger
5 * together, and while strangers flood each one a signed-in push still
6 * lands. Addresses are told apart by `X-Forwarded-For`, which the forge
7 * reads from this machine (`http/peer.ts`).
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm, writeFile } from "node:fs/promises";
11 import { connect, type Socket } from "node:net";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { createRepo } from "../src/actions/repos.ts";
16 import { createApp as createAppRecord } from "../src/core/apps.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import type { User } from "../src/core/users.ts";
19 import { transferGate } from "../src/git/env.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import {
22 heldNow,
23 setStrangerLimits,
24 STRANGER_LIMITS,
25 strangerCodeSearch,
26 } from "../src/http/strangers.ts";
27 import { Fresh } from "../src/kernel/fresh.ts";
28 import type { Ctx } from "../src/tui/app/app.ts";
29 import { forgeSearch } from "../src/tui/forge.ts";
30 import { Session } from "../src/tui/session.ts";
31 import { seedAdmin } from "./helpers/admin.ts";
32 import { gitClientEnv } from "./helpers/git-client.ts";
33
34 describe("Fresh: one piece of work per key per while", () => {
35 test("kept for ttl, made again after, by the clock it is given", () => {
36 let at = 1_000;
37 let made = 0;
38 const kept = new Fresh<number>(30_000, 16, () => at);
39 expect(kept.get("k", () => ++made)).toBe(1);
40 at += 29_999;
41 expect(kept.get("k", () => ++made)).toBe(1);
42 at += 1; // 30 000 ms after the first: stale
43 expect(kept.get("k", () => ++made)).toBe(2);
44 });
45
46 test("callers inside a making share one promise; a rejection is forgotten", async () => {
47 const kept = new Fresh<Promise<string>>(60_000, 16, () => 0);
48 let calls = 0;
49 const a = kept.get("x", async () => {
50 calls++;
51 throw new Error("no");
52 });
53 const b = kept.get("x", async () => "never");
54 expect(b).toBe(a);
55 await expect(a).rejects.toThrow("no");
56 expect(await kept.get("x", async () => "again")).toBe("again");
57 expect(calls).toBe(1);
58 });
59
60 test("keys are bounded, the oldest dropped first", () => {
61 const kept = new Fresh<string>(60_000, 2, () => 0);
62 kept.get("a", () => "a1");
63 kept.get("b", () => "b1");
64 kept.get("c", () => "c1");
65 expect(kept.get("a", () => "a2")).toBe("a2");
66 expect(kept.get("c", () => "c2")).toBe("c1");
67 });
68 });
69
70 describe("strangers at every door", () => {
71 let dataDir: string;
72 let app: App;
73 let servers: Servers;
74 let base: string;
75 let port: number;
76 let token: string;
77 let ada: User;
78 let next = 1;
79 /** A fresh address, so one test's minute is never another's. */
80 const address = (): string => `203.0.113.${next++}`;
81
82 beforeAll(async () => {
83 dataDir = await mkdtemp(join(tmpdir(), "forge-strangers-"));
84 app = createApp({ dataDir, port: 0 });
85 servers = await serve(app);
86 base = servers.url;
87 port = servers.port;
88 const owner = await seedAdmin(app, {
89 username: "ada",
90 email: "[email protected]",
91 password: "owner-passphrase",
92 });
93 if (!owner.ok) throw new Error(owner.error);
94 token = createToken(app.db, owner.value.id, "t", "admin").token;
95 ada = owner.value;
96 for (const name of ["garden", "shed"]) {
97 const repo = await createRepo(app, owner.value, { name, visibility: "public" });
98 if (!repo.ok) throw new Error(repo.error);
99 }
100 });
101 afterAll(async () => {
102 servers.stop();
103 app.close();
104 await rm(dataDir, { recursive: true, force: true });
105 });
106
107 const from = (ip: string, extra: Record<string, string> = {}) => ({
108 "x-forwarded-for": ip,
109 ...extra,
110 });
111 const status = async (path: string, headers: Record<string, string>) => {
112 const res = await fetch(`${base}${path}`, { headers });
113 await res.arrayBuffer();
114 return res.status;
115 };
116 const until = async (done: () => boolean, ms = 10_000): Promise<boolean> => {
117 const end = Date.now() + ms;
118 while (!done()) {
119 if (Date.now() > end) return false;
120 await Bun.sleep(25);
121 }
122 return true;
123 };
124
125 /**
126 * A fetch's negotiation opened and never sent: the headers of a chunked
127 * POST to upload-pack, then nothing. Resolves with the socket and, if the
128 * forge answers before the body would, its status line.
129 */
130 function stall(ip: string): { socket: Socket; answer: Promise<string> } {
131 const socket = connect(port, "127.0.0.1");
132 let text = "";
133 const answer = new Promise<string>((resolve) => {
134 socket.on("data", (chunk) => {
135 text += chunk.toString("latin1");
136 if (text.includes("\r\n")) resolve(text.slice(0, text.indexOf("\r\n")));
137 });
138 socket.on("close", () => resolve(text.slice(0, text.indexOf("\r\n"))));
139 socket.on("error", () => resolve(""));
140 });
141 socket.write(
142 `POST /ada/garden.git/git-upload-pack HTTP/1.1\r\nHost: x\r\nX-Forwarded-For: ${ip}\r\nContent-Type: application/x-git-upload-pack-request\r\nTransfer-Encoding: chunked\r\n\r\n`,
143 );
144 return { socket, answer };
145 }
146
147 test("a stranger's health checks are never throttled", async () => {
148 const ip = address();
149 for (let i = 0; i < 20; i++) expect(await status("/healthz", from(ip))).toBe(200);
150 });
151
152 test("anonymous API reads: per address, then every stranger together; a token is its own", async () => {
153 setStrangerLimits(app, {
154 perAddress: { ...STRANGER_LIMITS.perAddress, api: 5 },
155 forge: { ...STRANGER_LIMITS.forge, api: 8 },
156 });
157 try {
158 const a = address();
159 for (let i = 0; i < 5; i++) expect(await status("/api/v1/repos", from(a))).toBe(200);
160 const refused = await fetch(`${base}/api/v1/repos`, { headers: from(a) });
161 expect(refused.status).toBe(429);
162 expect(Number(refused.headers.get("retry-after"))).toBeGreaterThan(0);
163 expect(((await refused.json()) as { error: string }).error).toBe("rate_limited");
164 // Another address has its own five — until the forge's eight are spent.
165 const b = address();
166 for (let i = 0; i < 3; i++) expect(await status("/api/v1/repos", from(b))).toBe(200);
167 expect(await status("/api/v1/repos", from(b))).toBe(429);
168 // A page is a person's, and a token is counted as a token.
169 expect(await status("/explore", from(a))).toBe(200);
170 expect(
171 await status("/api/v1/repos", from(a, { authorization: `Bearer ${token}` })),
172 ).toBe(200);
173 } finally {
174 setStrangerLimits(app, STRANGER_LIMITS);
175 }
176 });
177
178 test("anonymous ref advertisements are counted per address", async () => {
179 setStrangerLimits(app, { perAddress: { ...STRANGER_LIMITS.perAddress, git: 3 } });
180 try {
181 const ip = address();
182 const refs = "/ada/garden.git/info/refs?service=git-upload-pack";
183 for (let i = 0; i < 3; i++) expect(await status(refs, from(ip))).toBe(200);
184 expect(await status(refs, from(ip))).toBe(429);
185 expect(await status(refs, from(address()))).toBe(200);
186 // Access is decided first: a repository nobody may read still asks
187 // for a credential, not a retry.
188 expect(await status("/ada/nothing.git/info/refs?service=git-upload-pack", from(ip))).toBe(401);
189 } finally {
190 setStrangerLimits(app, STRANGER_LIMITS);
191 }
192 });
193
194 test("a fetch that stops sending ends, and gives its transfer back", async () => {
195 setStrangerLimits(app, { transferIdleMs: 300 });
196 const { socket } = stall(address());
197 try {
198 expect(await until(() => heldNow(app, "transfer") === 1)).toBe(true);
199 expect(await until(() => heldNow(app, "transfer") === 0)).toBe(true);
200 } finally {
201 socket.destroy();
202 setStrangerLimits(app, STRANGER_LIMITS);
203 }
204 });
205
206 test("a flood of stalled anonymous clones takes its share, and a signed-in push still lands", async () => {
207 const share = Math.max(1, Math.floor(transferGate.limit * STRANGER_LIMITS.transferShare));
208 const stalls: ReturnType<typeof stall>[] = [];
209 try {
210 // One address holds its four, then is refused.
211 const one = address();
212 for (let i = 0; i < STRANGER_LIMITS.transfersPerAddress; i++) stalls.push(stall(one));
213 expect(await until(() => heldNow(app, "transfer") === STRANGER_LIMITS.transfersPerAddress)).toBe(true);
214 const own = stall(one);
215 stalls.push(own);
216 expect(await own.answer).toStartWith("HTTP/1.1 429");
217 // Many addresses together hold the strangers' share and no more.
218 for (let i = 0; i < share; i++) stalls.push(stall(address()));
219 expect(await until(() => heldNow(app, "transfer") === share)).toBe(true);
220 const late = stall(address());
221 stalls.push(late);
222 expect(await late.answer).toStartWith("HTTP/1.1 429");
223 expect(heldNow(app, "transfer")).toBe(share);
224
225 // While they hold it, a signed-in push is made and lands.
226 const work = await mkdtemp(join(tmpdir(), "forge-strangers-push-"));
227 try {
228 const git = async (args: string[]) =>
229 await Bun.spawn(["git", ...args], {
230 cwd: work,
231 env: gitClientEnv(),
232 stdout: "ignore",
233 stderr: "ignore",
234 }).exited;
235 expect(await git(["init", "-q", "-b", "main"])).toBe(0);
236 await writeFile(join(work, "README.md"), "# shed\n");
237 expect(await git(["add", "."])).toBe(0);
238 expect(await git(["commit", "-q", "-m", "first"])).toBe(0);
239 const remote = `${base.replace("http://", `http://ada:${token}@`)}/ada/shed.git`;
240 expect(await git(["push", "-q", remote, "main"])).toBe(0);
241 } finally {
242 await rm(work, { recursive: true, force: true });
243 }
244 } finally {
245 for (const s of stalls) s.socket.destroy();
246 }
247 // Hung up, every stalled transfer gives its permit back.
248 expect(await until(() => heldNow(app, "transfer") === 0)).toBe(true);
249 }, 60_000);
250
251 test("anonymous code search: per address, and one at a time for every stranger", async () => {
252 setStrangerLimits(app, { perAddress: { ...STRANGER_LIMITS.perAddress, code: 2 } });
253 try {
254 const ip = address();
255 const code = "/api/v1/search?q=shed&kind=code";
256 expect(await status(code, from(ip))).toBe(200);
257 expect(await status(code, from(ip))).toBe(200);
258 expect(await status(code, from(ip))).toBe(429);
259 // A search that greps nothing is not a code search.
260 expect(await status("/api/v1/search?q=shed&kind=repos", from(ip))).toBe(200);
261
262 // Another stranger's search is running: a second is refused on the
263 // API, and the page answers without the code and says why.
264 const busy = strangerCodeSearch(app, new Request(`${base}/x`));
265 expect(busy.allowed).toBe(true);
266 try {
267 expect(await status(code, from(address()))).toBe(429);
268 const page = await fetch(`${base}/search?q=shed`, { headers: from(address()) });
269 expect(page.status).toBe(200);
270 expect(await page.text()).toContain("data-code-busy");
271 // Someone signed in searches code as ever.
272 expect(
273 await status(code, from(address(), { authorization: `Bearer ${token}` })),
274 ).toBe(200);
275 } finally {
276 if (busy.allowed) busy.release();
277 }
278 expect(heldNow(app, "code")).toBe(0);
279 } finally {
280 setStrangerLimits(app, STRANGER_LIMITS);
281 }
282 });
283
284 test("a terminal guest's palette search takes the same code slot, keyed on its peer", async () => {
285 const wrote = await fetch(`${base}/api/v1/repos/ada/shed/contents`, {
286 method: "POST",
287 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
288 body: JSON.stringify({
289 branch: "main",
290 message: "first",
291 files: [{ path: "rake.txt", content: "a trowel and a wheelbarrow\n" }],
292 }),
293 });
294 expect(wrote.status).toBeLessThan(300);
295 await wrote.arrayBuffer();
296
297 // A guest: any SSH key the forge does not know.
298 const guest = new Session(app, {
299 sshUsername: "anyone",
300 remoteAddress: address(),
301 fingerprint: "SHA256:unknown",
302 });
303 const toasts: string[] = [];
304 const ctx = { icons: "plain", toast: (t: string) => toasts.push(t) } as unknown as Ctx;
305 const ask = (q: string) =>
306 forgeSearch(guest)(ctx)(q, false, new AbortController().signal);
307 const code = (items: { id: string }[]) =>
308 items.filter((i) => i.id.startsWith("code:") && i.id !== "code:not-searched");
309
310 expect(code(await ask("wheelbarrow")).length).toBeGreaterThan(0);
311 expect(heldNow(app, "code")).toBe(0);
312
313 const busy = strangerCodeSearch(app, new Request(`${base}/x`));
314 expect(busy.allowed).toBe(true);
315 try {
316 const items = await ask("wheelbarrow");
317 expect(code(items)).toEqual([]);
318 const note = items.find((i) => i.id === "code:not-searched");
319 expect(note?.title).toBe("Code was not searched");
320 // The rest of the search still answers.
321 expect((await ask("shed")).some((i) => i.id.startsWith("repo:"))).toBe(true);
322 } finally {
323 if (busy.allowed) busy.release();
324 }
325
326 // Its own minute, counted under its address.
327 setStrangerLimits(app, { perAddress: { ...STRANGER_LIMITS.perAddress, code: 1 } });
328 try {
329 expect(code(await ask("wheelbarrow")).length).toBeGreaterThan(0);
330 expect(code(await ask("wheelbarrow"))).toEqual([]);
331 // A blank query greps nothing and spends nothing.
332 expect((await ask(" ")).some((i) => i.id === "code:not-searched")).toBe(false);
333 } finally {
334 setStrangerLimits(app, STRANGER_LIMITS);
335 }
336 expect(heldNow(app, "code")).toBe(0);
337 });
338
339 test("a blank query on the page does not take a stranger's code slot", async () => {
340 const busy = strangerCodeSearch(app, new Request(`${base}/x`));
341 expect(busy.allowed).toBe(true);
342 try {
343 const page = await fetch(`${base}/search?q=`, { headers: from(address()) });
344 expect(page.status).toBe(200);
345 expect(await page.text()).not.toContain("data-code-busy");
346 } finally {
347 if (busy.allowed) busy.release();
348 }
349 });
350
351 test("event streams: per stranger address and for every stranger, given back on hang-up", async () => {
352 setStrangerLimits(app, { streamsPerAddress: 2, strangerStreams: 3 });
353 const open: AbortController[] = [];
354 const stream = async (headers: Record<string, string>): Promise<number> => {
355 const abort = new AbortController();
356 open.push(abort);
357 const res = await fetch(`${base}/events`, { headers, signal: abort.signal });
358 if (res.status !== 200) await res.arrayBuffer();
359 return res.status;
360 };
361 try {
362 const a = address();
363 expect(await stream(from(a))).toBe(200);
364 expect(await stream(from(a))).toBe(200);
365 expect(await stream(from(a))).toBe(429);
366 expect(await stream(from(address()))).toBe(200);
367 expect(await stream(from(address()))).toBe(429);
368 expect(heldNow(app, "stream:strangers")).toBe(3);
369 // Someone signed in is not a stranger.
370 expect(await stream(from(a, { authorization: `Bearer ${token}` }))).toBe(200);
371 } finally {
372 for (const abort of open) abort.abort();
373 setStrangerLimits(app, STRANGER_LIMITS);
374 }
375 expect(await until(() => heldNow(app, "stream:*") === 0)).toBe(true);
376 });
377
378 test("a stranger's spawn plans are counted per address", async () => {
379 setStrangerLimits(app, { perAddress: { ...STRANGER_LIMITS.perAddress, plan: 2 } });
380 try {
381 const ip = address();
382 const plan = () =>
383 fetch(`${base}/api/v1/forges`, {
384 method: "POST",
385 headers: from(ip, { "content-type": "application/json", origin: base }),
386 body: JSON.stringify({ name: "kiln", dryRun: true }),
387 }).then(async (r) => {
388 await r.arrayBuffer();
389 return r.status;
390 });
391 expect(await plan()).not.toBe(429);
392 expect(await plan()).not.toBe(429);
393 expect(await plan()).toBe(429);
394 } finally {
395 setStrangerLimits(app, STRANGER_LIMITS);
396 }
397 });
398
399 test("a stranger's HEAD to /events gives its stream back at once", async () => {
400 expect(heldNow(app, "stream:*")).toBe(0);
401 const res = await fetch(`${base}/events`, { method: "HEAD", headers: from(address()) });
402 expect(res.status).toBe(200);
403 await res.arrayBuffer();
404 // The GET's stream behind the HEAD is cancelled, not left holding a slot.
405 expect(await until(() => heldNow(app, "stream:*") === 0, 2_000)).toBe(true);
406 });
407
408 test("the audit card is made once in its while, however many ask", async () => {
409 const first = await (await fetch(`${base}/og/audit.svg`)).text();
410 expect(first).toContain("<svg");
411 const repo = await createRepo(app, ada, { name: "greenhouse", visibility: "public" });
412 if (!repo.ok) throw new Error(String(repo.error));
413 const rec = createAppRecord(app.db, { repoId: repo.value.id, slug: "greenhouse", access: "public" });
414 expect(rec.ok).toBe(true);
415 expect(await (await fetch(`${base}/og/audit.svg`)).text()).toBe(first);
416 });
417
418 test("the sitemap is made once a minute, however many ask", async () => {
419 const first = await (await fetch(`${base}/sitemap.xml`)).text();
420 expect(first).toContain("/ada/garden</loc>");
421 const made = await createRepo(app, ada, {
422 name: "orchard",
423 visibility: "public",
424 });
425 expect(made.ok).toBe(true);
426 expect(await (await fetch(`${base}/sitemap.xml`)).text()).toBe(first);
427 // Another Host is answered under its own origin from the same rows: a
428 // caller varying Host cannot make the forge read them again.
429 const other = await (
430 await fetch(`${base}/sitemap.xml`, { headers: { host: "elsewhere.test" } })
431 ).text();
432 expect(other).toContain("<loc>http://elsewhere.test/ada/garden</loc>");
433 expect(other).not.toContain("orchard");
434 });
435 });
test/system-stream.test.ts +1 −1
@@ -122,7 +122,7 @@ describe("/system over server-sent events", () => {
122122 let emitter: ((e: string, d: unknown) => void) | undefined;
123123 let ready: (() => boolean) | undefined;
124124 let closed = false;
125 const res = eventStream(new Request("http://x/s"), (emit, _close, r) => {
125 const res = eventStream({} as App, new Request("http://x/s"), (emit, _close, r) => {
126126 emitter = emit;
127127 ready = r;
128128 return () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs