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

signupAllowed(ctx) holds two windows, per source and per forge, with signup.per_source and signup.per_forge on /admin/limits #2327

Merged opened this

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

Merged

scout/forge:scout/2321-signupallowed-ctx-holds-two-windows-per → main 1 commit fork

  • Merged. scout/2321-signupallowed-ctx-holds-two-windows-per was merged into main as bae7b00.

Review

Sign in to review this change.
3 files changed +107 −2
src/settings/limits.ts +19 −1
@@ -32,7 +32,9 @@ export type LimitKey =
3232 | "front.hidden_max"
3333 | "share.daily_max"
3434 | "signin.per_account"
35 | "signin.per_forge";
35 | "signin.per_forge"
36 | "signup.per_source"
37 | "signup.per_forge";
3638
3739 export interface LimitDef {
3840 readonly label: string;
@@ -132,6 +134,22 @@ export const LIMITS: Record<LimitKey, LimitDef> = {
132134 max: 100_000,
133135 reason: "Spread across many accounts, guessing still burns CPU on hashes, so the forge as a whole has a ceiling.",
134136 },
137 "signup.per_source": {
138 label: "Sign-ups per source a minute",
139 unit: "sign-ups",
140 default: 5,
141 min: 1,
142 max: 1_000,
143 reason: "Every sign-up costs a password hash and an account, and one source has no need to make many.",
144 },
145 "signup.per_forge": {
146 label: "Sign-ups per forge a minute",
147 unit: "sign-ups",
148 default: 60,
149 min: 1,
150 max: 100_000,
151 reason: "Spread across many sources, sign-ups still cost hashes and accounts, so the forge as a whole has a ceiling.",
152 },
135153 };
136154
137155 export const LIMIT_KEYS = Object.keys(LIMITS) as LimitKey[];
src/web/pages/site.ts +26 −1
@@ -11,7 +11,7 @@
1111 import { storageLimitMessage } from "../../core/storage.ts";
1212 import { limitValue } from "../../settings/limits.ts";
1313 import { signOutHere } from "../../actions/signins.ts";
14 import { clientAddress } from "../../http/peer.ts";
14 import { bucketOf, clientAddress } from "../../http/peer.ts";
1515 import { verifyEmail } from "../../actions/verify.ts";
1616 import { STRANGER_MAX } from "../../http/limits.ts";
1717 import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts";
@@ -456,6 +456,31 @@ export function signinAllowed(ctx: Ctx, username: string): number | undefined {
456456 return whole.allowed ? undefined : whole.retryAfter;
457457 }
458458
459 /**
460 * Sign-ups, throttled the same way, by the socket's peer (`clientAddress`,
461 * which reads a forwarding header only from a proxy the operator trusts):
462 * a source may make a few accounts a minute and the forge as a whole more.
463 * Not yet asked by any signup door. A refused call is not counted.
464 */
465 const signupLimits = new WeakMap<Ctx["app"], RateLimiter>();
466
467 export function signupAllowed(ctx: Ctx): number | undefined {
468 let limiter = signupLimits.get(ctx.app);
469 if (limiter === undefined) {
470 limiter = new RateLimiter({ perMinute: 0, adminPerMinute: 0, enabled: true });
471 signupLimits.set(ctx.app, limiter);
472 }
473 limiter.setLimits({
474 perMinute: limitValue(ctx.app.db, "signup.per_source"),
475 adminPerMinute: limitValue(ctx.app.db, "signup.per_forge"),
476 enabled: true,
477 });
478 const one = limiter.take(`source:${bucketOf(clientAddress(ctx.request))}`, "ro");
479 if (!one.allowed) return one.retryAfter;
480 const whole = limiter.take("forge", "admin");
481 return whole.allowed ? undefined : whole.retryAfter;
482 }
483
459484 export async function signinSubmit(ctx: Ctx): Promise<Response> {
460485 // Posted by people with no account yet: a stranger's form.
461486 const form = await readForm(ctx, undefined, STRANGER_MAX);
test/signup-allowed.test.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * signupAllowed holds two windows: a source may sign up five times a minute,
3 * the forge sixty, whoever asks. A refused call is not counted.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { notePeer } from "../src/http/peer.ts";
11 import { LIMITS, LIMIT_KEYS } from "../src/settings/limits.ts";
12 import { signupAllowed } from "../src/web/pages/site.ts";
13 import type { Ctx } from "../src/http/context.ts";
14
15 describe("signupAllowed", () => {
16 let dataDir: string;
17 let app: App;
18
19 const from = (address: string, forge: App = app): number | undefined => {
20 const request = new Request("http://forge.local/signup");
21 notePeer(request, address);
22 return signupAllowed({ app: forge, request } as unknown as Ctx);
23 };
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-allowed-"));
27 app = createApp({ dataDir, port: 0 });
28 });
29
30 afterAll(async () => {
31 app.close();
32 await rm(dataDir, { recursive: true, force: true });
33 });
34
35 test("the limits are listed with production defaults", () => {
36 expect(LIMIT_KEYS).toContain("signup.per_source");
37 expect(LIMIT_KEYS).toContain("signup.per_forge");
38 expect(LIMITS["signup.per_source"].default).toBe(5);
39 expect(LIMITS["signup.per_forge"].default).toBe(60);
40 });
41
42 test("rapid calls from one source are throttled, another source is not", () => {
43 for (let i = 0; i < 5; i++) expect(from("203.0.113.1")).toBeUndefined();
44 const wait = from("203.0.113.1");
45 expect(wait).toBeGreaterThan(0);
46 expect(wait).toBeLessThanOrEqual(60);
47 expect(from("203.0.113.2")).toBeUndefined();
48 });
49
50 test("the per-forge window holds across sources", async () => {
51 const dir = await mkdtemp(join(tmpdir(), "forge-signup-allowed-"));
52 const forge = createApp({ dataDir: dir, port: 0 });
53 try {
54 // Sixty sources, one sign-up each, fill the forge's window.
55 for (let i = 0; i < 60; i++) expect(from(`198.51.100.${i + 1}`, forge)).toBeUndefined();
56 expect(from("192.0.2.200", forge)).toBeGreaterThan(0);
57 } finally {
58 forge.close();
59 await rm(dir, { recursive: true, force: true });
60 }
61 });
62 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs