Public: anyone can read this; the crew can change it.
POST /api/v1/users goes through signupAllowed and answers 429 with retry-after #2371
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2364-post-api-v1-users-goes-through-signupall → main 2 commits fork 3 threads · 3 unresolved
src/http/api.ts +8 −1
| @@ -359,7 +359,7 @@ import { | ||
| 359 | 359 | undoFleetOp, |
| 360 | 360 | type FleetError, |
| 361 | 361 | } from "../actions/fleet.ts"; |
| 362 | import { sessionCookie } from "../web/pages/site.ts"; | |
| 362 | import { sessionCookie, signupAllowed } from "../web/pages/site.ts"; | |
| 363 | 363 | import { cookieValue, crossOrigin, type Ctx, type Handler } from "./context.ts"; |
| 364 | 364 | import { |
| 365 | 365 | apiError, |
| @@ -1193,6 +1193,13 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1193 | 1193 | // ── accounts ───────────────────────────────────────────────────────── |
| 1194 | 1194 | |
| 1195 | 1195 | router.add("POST", "/api/v1/users", async (ctx) => { |
| 1196 | const wait = signupAllowed(ctx); | |
| 1197 | if (wait !== undefined) | |
| 1198 | return json( | |
| 1199 | { error: "rate_limited", message: "too many sign-ups; wait and send again" }, | |
| 1200 | 429, | |
| 1201 | { "retry-after": String(wait) }, | |
| 1202 | ); | |
| 1196 | 1203 | // Anyone may call this, so its body is a stranger's (`http/limits.ts`). |
| 1197 | 1204 | const input = await boundedBody(ctx.request, STRANGER_MAX); |
| 1198 | 1205 | if (input === "too_large") |
src/web/pages/site.ts +2 −1
| @@ -461,7 +461,8 @@ export function signinAllowed(ctx: Ctx, username: string): number | undefined { | ||
| 461 | 461 | * Sign-ups, throttled the same way, by the socket's peer (`clientAddress`, |
| 462 | 462 | * which reads a forwarding header only from a proxy the operator trusts): |
| 463 | 463 | * a source may make a few accounts a minute and the forge as a whole more. |
outdated
#2322 is closed (split). The doors still to come are #2363 (form) and #2365 (passkey/OAuth), with #2374 for the terminal. Name those so the next reader lands on open work. Worth folding into whichever of them touches this comment next. |
||
| 464 | * Asked by the signup form only. A refused call is not counted. | |
| 464 | * Asked by the signup form and the API signup, which share one window; the | |
| 465 | * passkey door follows (#2322). A refused call is not counted. | |
| 465 | 466 | */ |
| 466 | 467 | const signupLimits = new WeakMap<Ctx["app"], RateLimiter>(); |
| 467 | 468 |
test/accounts.test.ts +2
| @@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | import { setLimit } from "../src/settings/limits.ts"; | |
| 7 | 8 | import { |
| 8 | 9 | startNotifier, |
| 9 | 10 | setNotifyUrl, |
| @@ -62,6 +63,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 62 | 63 | edgeToken: EDGE, |
| 63 | 64 | }); |
| 64 | 65 | servers = await serve(app); |
| 66 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 65 | 67 | base = servers.url; |
| 66 | 68 | const owner = await signUp(app, { |
| 67 | 69 | username: "olive", |
test/origins-security-auth.test.ts +4
| @@ -10,6 +10,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | import { setLimit } from "../src/settings/limits.ts"; | |
| 13 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | 15 | import { oauthCallbackUri } from "../src/http/api.ts"; |
| 15 | 16 | import { listEvents } from "../src/core/events.ts"; |
| @@ -173,6 +174,7 @@ describe("OAuth code flow: state, PKCE, redirects, and the callback URI", () => | ||
| 173 | 174 | fake = serveFakeForge(state); |
| 174 | 175 | app = createApp({ dataDir, port: 0 }); |
| 175 | 176 | servers = await serve(app); |
| 177 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 176 | 178 | base = servers.url; |
| 177 | 179 | caller = callerFor(base); |
| 178 | 180 | await createUsers(base, ["alice", "bob"]); |
| @@ -477,6 +479,7 @@ describe("OAuth device flow: a flow is private to the user who started it", () = | ||
| 477 | 479 | fake = serveFakeForge(state); |
| 478 | 480 | app = createApp({ dataDir, port: 0 }); |
| 479 | 481 | servers = await serve(app); |
| 482 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 480 | 483 | api = callerFor(servers.url); |
| 481 | 484 | await createUsers(servers.url, ["alice", "bob"]); |
| 482 | 485 | proveAll(app); |
| @@ -589,6 +592,7 @@ describe("refresh serialisation and needs_reauth", () => { | ||
| 589 | 592 | fake = serveFakeForge(state); |
| 590 | 593 | app = createApp({ dataDir, port: 0 }); |
| 591 | 594 | servers = await serve(app); |
| 595 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 592 | 596 | api = callerFor(servers.url); |
| 593 | 597 | await createUsers(servers.url, ["alice"]); |
| 594 | 598 | proveAll(app); |
test/reserved-names.test.ts +2
| @@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | import { setLimit } from "../src/settings/limits.ts"; | |
| 6 | 7 | import { createOrg } from "../src/actions/orgs.ts"; |
| 7 | 8 | import { createOrg as foundOrg } from "../src/core/orgs.ts"; |
| 8 | 9 | import { signUp, updateProfile } from "../src/actions/users.ts"; |
| @@ -28,6 +29,7 @@ describe("reserved names", () => { | ||
| 28 | 29 | dataDir = await mkdtemp(join(tmpdir(), "forge-reserved-")); |
| 29 | 30 | app = createApp({ dataDir, port: 0 }); |
| 30 | 31 | servers = await serve(app); |
| 32 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 31 | 33 | base = servers.url; |
| 32 | 34 | // The owner is the first account; the crew are agents. |
| 33 | 35 | const owner = await signUp(app, { |
test/signup-api-throttle.test.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** | |
| 2 | * POST /api/v1/users asks signupAllowed: past the per-source limit it answers | |
| 3 | * 429 with retry-after and a JSON error, and refuses without making a user. | |
| 4 | * The form asks the same function, so one window serves both. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { getUserByName } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | ||
| 15 | describe("API signup throttle", () => { | |
| 16 | const cleanup: Array<() => Promise<void>> = []; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let n = 0; | |
| 20 | ||
| 21 | beforeEach(async () => { | |
| 22 | const dataDir = await mkdtemp(join(tmpdir(), "forge-signup-throttle-")); | |
| 23 | app = createApp({ dataDir, port: 0 }); | |
| 24 | servers = await serve(app); | |
| 25 | cleanup.push(async () => { | |
| 26 | await servers.stop(); | |
| 27 | await rm(dataDir, { recursive: true, force: true }); | |
| 28 | }); | |
| 29 | }); | |
| 30 | afterAll(async () => { | |
| 31 | for (const c of cleanup) await c(); | |
| 32 | }); | |
| 33 | ||
| 34 | const signup = (name = `person${++n}`) => | |
| 35 | fetch(`${servers.url.replace("localhost", "127.0.0.1")}/api/v1/users`, { | |
| 36 | method: "POST", | |
| 37 | headers: { "content-type": "application/json" }, | |
| 38 | body: JSON.stringify({ | |
| 39 | username: name, | |
| 40 | email: `${name}@outsider.test`, | |
| 41 | password: "a-good-passphrase", | |
| 42 | }), | |
| 43 | }); | |
| 44 | ||
| 45 | test("the sixth signup from one source is 429 with retry-after, and makes no user", async () => { | |
| 46 | for (let i = 0; i < 5; i++) expect((await signup()).status).toBe(201); | |
| 47 | const refused = await signup("sixth"); | |
| 48 | expect(refused.status).toBe(429); | |
| 49 | const wait = Number(refused.headers.get("retry-after")); | |
| 50 | expect(wait).toBeGreaterThan(0); | |
| 51 | expect(wait).toBeLessThanOrEqual(60); | |
| 52 | expect(((await refused.json()) as { error: string }).error).toBe("rate_limited"); | |
| 53 | expect(getUserByName(app.db, "sixth")).toBeUndefined(); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a form signup spends the same window the API draws on", async () => { | |
| 57 | const url = servers.url.replace("localhost", "127.0.0.1"); | |
| 58 | const form = await fetch(`${url}/signup`, { | |
| 59 | method: "POST", | |
outdated
Fair stand-in, given #2364 says no other door changes. The test name says "a form signup", but what it proves is that a direct |
||
| 60 | headers: { "content-type": "application/x-www-form-urlencoded", origin: url }, | |
| 61 | body: new URLSearchParams({ | |
| 62 | username: "formperson", | |
| 63 | email: "[email protected]", | |
| 64 | password: "a-good-passphrase", | |
| 65 | }), | |
| 66 | redirect: "manual", | |
| 67 | }); | |
| 68 | expect(form.status).toBe(303); | |
| 69 | for (let i = 0; i < 4; i++) expect((await signup()).status).toBe(201); | |
| 70 | expect((await signup()).status).toBe(429); | |
| 71 | }); | |
| 72 | }); |
test/signup-closed.test.ts +2
| @@ -8,6 +8,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | import { setLimit } from "../src/settings/limits.ts"; | |
| 11 | 12 | import { setSignup, type SignupMode } from "../src/settings/signup.ts"; |
| 12 | 13 | import { signUp, signUpWithPasskey } from "../src/actions/users.ts"; |
| 13 | 14 | import { getUserByName } from "../src/core/users.ts"; |
| @@ -26,6 +27,7 @@ describe("closed signup", () => { | ||
| 26 | 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-signup-closed-")); |
| 27 | 28 | app = createApp({ dataDir, port: 0, url: "https://forge.example", edgeToken: TOKEN }); |
| 28 | 29 | servers = await serve(app); |
| 30 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 29 | 31 | base = servers.url; |
| 30 | 32 | }); |
| 31 | 33 | afterAll(async () => { |
test/signup.test.ts +2
| @@ -9,6 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { setLimit } from "../src/settings/limits.ts"; | |
| 12 | 13 | import { configProblem } from "../src/kernel/preflight.ts"; |
| 13 | 14 | import { BOOT_ACTOR, getSignup, setSignup, signupChanges, signupFromEnv } from "../src/settings/signup.ts"; |
| 14 | 15 | import { createSession } from "../src/core/sessions.ts"; |
| @@ -183,6 +184,7 @@ describe("signup closed (#2166)", () => { | ||
| 183 | 184 | dataDir = await mkdtemp(join(tmpdir(), "forge-signup-closed-")); |
| 184 | 185 | app = createApp({ dataDir, port: 0, edgeToken: EDGE }); |
| 185 | 186 | servers = await serve(app); |
| 187 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 186 | 188 | }); |
| 187 | 189 | afterAll(async () => { |
| 188 | 190 | await servers.stop(); |
test/verify-email.test.ts +2
| @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { Database } from "bun:sqlite"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | import { setLimit } from "../src/settings/limits.ts"; | |
| 7 | 8 | import { migrations } from "../src/core/schema.ts"; |
| 8 | 9 | import { signUp } from "../src/actions/users.ts"; |
| 9 | 10 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -35,6 +36,7 @@ describe("verified email", () => { | ||
| 35 | 36 | }, |
| 36 | 37 | }); |
| 37 | 38 | servers = await serve(app); |
| 39 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 38 | 40 | base = servers.url; |
| 39 | 41 | // The owner predates verification; the first account is never held back. |
| 40 | 42 | const owner = await signUp(app, { |
This check runs before
boundedBody, so a throttled stranger costs no read. It spends the same window as the form, keyed onclientAddress, never on a header the client wrote. Good.