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

POST /api/v1/users goes through signupAllowed and answers 429 with retry-after #2371

Merged opened this

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

Merged

scout/forge:scout/2364-post-api-v1-users-goes-through-signupall → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/2364-post-api-v1-users-goes-through-signupall was merged into main as ab3c60c.

Review

Sign in to review this change.
9 files changed +96 −2
src/http/api.ts +8 −1
@@ -359,7 +359,7 @@ import {
359359 undoFleetOp,
360360 type FleetError,
361361 } from "../actions/fleet.ts";
362 import { sessionCookie } from "../web/pages/site.ts";
362 import { sessionCookie, signupAllowed } from "../web/pages/site.ts";
363363 import { cookieValue, crossOrigin, type Ctx, type Handler } from "./context.ts";
364364 import {
365365 apiError,
@@ -1193,6 +1193,13 @@ export function apiRoutes(router: Router<Handler>): void {
11931193 // ── accounts ─────────────────────────────────────────────────────────
11941194
11951195 router.add("POST", "/api/v1/users", async (ctx) => {
1196 const wait = signupAllowed(ctx);

This check runs before boundedBody, so a throttled stranger costs no read. It spends the same window as the form, keyed on clientAddress, never on a header the client wrote. Good.

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 );
11961203 // Anyone may call this, so its body is a stranger's (`http/limits.ts`).
11971204 const input = await boundedBody(ctx.request, STRANGER_MAX);
11981205 if (input === "too_large")
src/web/pages/site.ts +2 −1
@@ -461,7 +461,8 @@ export function signinAllowed(ctx: Ctx, username: string): number | undefined {
461461 * Sign-ups, throttled the same way, by the socket's peer (`clientAddress`,
462462 * which reads a forwarding header only from a proxy the operator trusts):
463463 * 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.
465466 */
466467 const signupLimits = new WeakMap<Ctx["app"], RateLimiter>();
467468
test/accounts.test.ts +2
@@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
7 import { setLimit } from "../src/settings/limits.ts";
78 import {
89 startNotifier,
910 setNotifyUrl,
@@ -62,6 +63,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
6263 edgeToken: EDGE,
6364 });
6465 servers = await serve(app);
66 setLimit(app.db, "signup.per_source", 1000, "setup");
6567 base = servers.url;
6668 const owner = await signUp(app, {
6769 username: "olive",
test/origins-security-auth.test.ts +4
@@ -10,6 +10,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import { createApp, type App } from "../src/app.ts";
13 import { setLimit } from "../src/settings/limits.ts";
1314 import { serve, type Servers } from "../src/http/server.ts";
1415 import { oauthCallbackUri } from "../src/http/api.ts";
1516 import { listEvents } from "../src/core/events.ts";
@@ -173,6 +174,7 @@ describe("OAuth code flow: state, PKCE, redirects, and the callback URI", () =>
173174 fake = serveFakeForge(state);
174175 app = createApp({ dataDir, port: 0 });
175176 servers = await serve(app);
177 setLimit(app.db, "signup.per_source", 1000, "setup");
176178 base = servers.url;
177179 caller = callerFor(base);
178180 await createUsers(base, ["alice", "bob"]);
@@ -477,6 +479,7 @@ describe("OAuth device flow: a flow is private to the user who started it", () =
477479 fake = serveFakeForge(state);
478480 app = createApp({ dataDir, port: 0 });
479481 servers = await serve(app);
482 setLimit(app.db, "signup.per_source", 1000, "setup");
480483 api = callerFor(servers.url);
481484 await createUsers(servers.url, ["alice", "bob"]);
482485 proveAll(app);
@@ -589,6 +592,7 @@ describe("refresh serialisation and needs_reauth", () => {
589592 fake = serveFakeForge(state);
590593 app = createApp({ dataDir, port: 0 });
591594 servers = await serve(app);
595 setLimit(app.db, "signup.per_source", 1000, "setup");
592596 api = callerFor(servers.url);
593597 await createUsers(servers.url, ["alice"]);
594598 proveAll(app);
test/reserved-names.test.ts +2
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { createApp, type App } from "../src/app.ts";
6 import { setLimit } from "../src/settings/limits.ts";
67 import { createOrg } from "../src/actions/orgs.ts";
78 import { createOrg as foundOrg } from "../src/core/orgs.ts";
89 import { signUp, updateProfile } from "../src/actions/users.ts";
@@ -28,6 +29,7 @@ describe("reserved names", () => {
2829 dataDir = await mkdtemp(join(tmpdir(), "forge-reserved-"));
2930 app = createApp({ dataDir, port: 0 });
3031 servers = await serve(app);
32 setLimit(app.db, "signup.per_source", 1000, "setup");
3133 base = servers.url;
3234 // The owner is the first account; the crew are agents.
3335 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 signupAllowed call and the API share one window. When #2363 wires the form, replace the direct call with a real POST /signup so this test proves what its name says.

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";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
11 import { setLimit } from "../src/settings/limits.ts";
1112 import { setSignup, type SignupMode } from "../src/settings/signup.ts";
1213 import { signUp, signUpWithPasskey } from "../src/actions/users.ts";
1314 import { getUserByName } from "../src/core/users.ts";
@@ -26,6 +27,7 @@ describe("closed signup", () => {
2627 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-closed-"));
2728 app = createApp({ dataDir, port: 0, url: "https://forge.example", edgeToken: TOKEN });
2829 servers = await serve(app);
30 setLimit(app.db, "signup.per_source", 1000, "setup");
2931 base = servers.url;
3032 });
3133 afterAll(async () => {
test/signup.test.ts +2
@@ -9,6 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
12 import { setLimit } from "../src/settings/limits.ts";
1213 import { configProblem } from "../src/kernel/preflight.ts";
1314 import { BOOT_ACTOR, getSignup, setSignup, signupChanges, signupFromEnv } from "../src/settings/signup.ts";
1415 import { createSession } from "../src/core/sessions.ts";
@@ -183,6 +184,7 @@ describe("signup closed (#2166)", () => {
183184 dataDir = await mkdtemp(join(tmpdir(), "forge-signup-closed-"));
184185 app = createApp({ dataDir, port: 0, edgeToken: EDGE });
185186 servers = await serve(app);
187 setLimit(app.db, "signup.per_source", 1000, "setup");
186188 });
187189 afterAll(async () => {
188190 await servers.stop();
test/verify-email.test.ts +2
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { Database } from "bun:sqlite";
66 import { createApp, type App } from "../src/app.ts";
7 import { setLimit } from "../src/settings/limits.ts";
78 import { migrations } from "../src/core/schema.ts";
89 import { signUp } from "../src/actions/users.ts";
910 import { createToken } from "../src/core/tokens.ts";
@@ -35,6 +36,7 @@ describe("verified email", () => {
3536 },
3637 });
3738 servers = await serve(app);
39 setLimit(app.db, "signup.per_source", 1000, "setup");
3840 base = servers.url;
3941 // The owner predates verification; the first account is never held back.
4042 const owner = await signUp(app, {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs