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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2321-signupallowed-ctx-holds-two-windows-per → main 1 commit fork
3 files
changed +107
−2
src/settings/limits.ts +19 −1
| @@ -32,7 +32,9 @@ export type LimitKey = | ||
| 32 | 32 | | "front.hidden_max" |
| 33 | 33 | | "share.daily_max" |
| 34 | 34 | | "signin.per_account" |
| 35 | | "signin.per_forge"; | |
| 35 | | "signin.per_forge" | |
| 36 | | "signup.per_source" | |
| 37 | | "signup.per_forge"; | |
| 36 | 38 | |
| 37 | 39 | export interface LimitDef { |
| 38 | 40 | readonly label: string; |
| @@ -132,6 +134,22 @@ export const LIMITS: Record<LimitKey, LimitDef> = { | ||
| 132 | 134 | max: 100_000, |
| 133 | 135 | reason: "Spread across many accounts, guessing still burns CPU on hashes, so the forge as a whole has a ceiling.", |
| 134 | 136 | }, |
| 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 | }, | |
| 135 | 153 | }; |
| 136 | 154 | |
| 137 | 155 | export const LIMIT_KEYS = Object.keys(LIMITS) as LimitKey[]; |
src/web/pages/site.ts +26 −1
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 12 | 12 | import { limitValue } from "../../settings/limits.ts"; |
| 13 | 13 | import { signOutHere } from "../../actions/signins.ts"; |
| 14 | import { clientAddress } from "../../http/peer.ts"; | |
| 14 | import { bucketOf, clientAddress } from "../../http/peer.ts"; | |
| 15 | 15 | import { verifyEmail } from "../../actions/verify.ts"; |
| 16 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | 17 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; |
| @@ -456,6 +456,31 @@ export function signinAllowed(ctx: Ctx, username: string): number | undefined { | ||
| 456 | 456 | return whole.allowed ? undefined : whole.retryAfter; |
| 457 | 457 | } |
| 458 | 458 | |
| 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 | ||
| 459 | 484 | export async function signinSubmit(ctx: Ctx): Promise<Response> { |
| 460 | 485 | // Posted by people with no account yet: a stranger's form. |
| 461 | 486 | 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 | }); |
new above
offline