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

The terminal's create-account screen goes through signupAllowed, keyed on the SSH peer #2398

Merged opened this

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

Merged

scout/forge:scout/2374-the-terminal-s-create-account-screen-goe → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/2374-the-terminal-s-create-account-screen-goe was merged into main as 543c1f1.

Review

Sign in to review this change.
4 files changed +137 −22
src/actions/signup-window.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Sign-ups, throttled in two windows: a source may make a few accounts a
3 * minute and the forge as a whole more. Every door that makes an account
4 * from a stranger's hands asks here with the peer's address — the socket's,
5 * never anything the client typed — so they share one count per source.
6 * A refused call is not counted.
7 */
8 import { RateLimiter } from "../core/ratelimit.ts";
9 import { bucketOf } from "../http/peer.ts";
10 import { limitValue } from "../settings/limits.ts";
11 import type { App } from "../app.ts";
12
13 const windows = new WeakMap<App, RateLimiter>();
14
15 /** Seconds to wait when refused, or undefined when this sign-up may go on. */
16 export function signupWindow(app: App, address: string): number | undefined {
17 let limiter = windows.get(app);
18 if (limiter === undefined) {
19 limiter = new RateLimiter({ perMinute: 0, adminPerMinute: 0, enabled: true });
20 windows.set(app, limiter);
21 }
22 // Read on every attempt, so a change on /admin/limits governs the next one.
23 limiter.setLimits({
24 perMinute: limitValue(app.db, "signup.per_source"),
25 adminPerMinute: limitValue(app.db, "signup.per_forge"),
26 enabled: true,
27 });
28 const one = limiter.take(`source:${bucketOf(address)}`, "ro");
29 if (!one.allowed) return one.retryAfter;
30 const whole = limiter.take("forge", "admin");
31 return whole.allowed ? undefined : whole.retryAfter;
32 }
33
34 export const waitText = (seconds: number): string =>
35 `Too many attempts. Try again in ${seconds === 1 ? "a second" : `${seconds} seconds`}.`;
src/tui/screens/signin.ts +6
@@ -9,6 +9,7 @@
99 */
1010
1111 import { SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts";
12 import { signupWindow, waitText } from "../../actions/signup-window.ts";
1213 import { hasAnyUser } from "../../core/users.ts";
1314 import type { Ctx, Screen } from "../app/app.ts";
1415 import { binding } from "../app/keys.ts";
@@ -66,6 +67,11 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen {
6667 string,
6768 string,
6869 ];
70 const wait = signupWindow(f.app, f.identity.remoteAddress);

Good placement: the check runs before signUp hashes a password. Note a refused attempt is not counted, but a validation failure after this check is, same as the web form, so that is consistent.

Charged before field validation: a typo in the email counts against the source. Fine for now, but validate first if people complain.

71 if (wait !== undefined) {
72 error = waitText(wait);
73 return;
74 }
6975 const created = await signUp(
7076 f.app,
7177 { username, email, password },
src/web/pages/site.ts +6 −22
@@ -12,7 +12,7 @@ import { NOT_A_MEMBER } from "../../core/roles.ts";
1212 import { storageLimitMessage } from "../../core/storage.ts";
1313 import { limitValue } from "../../settings/limits.ts";
1414 import { signOutHere } from "../../actions/signins.ts";
15 import { bucketOf, clientAddress } from "../../http/peer.ts";
15 import { clientAddress } from "../../http/peer.ts";
1616 import { verifyEmail } from "../../actions/verify.ts";
1717 import { STRANGER_MAX } from "../../http/limits.ts";
1818 import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts";
@@ -29,6 +29,7 @@ import { homeTop } from "../studio/asks_page.ts";
2929 import { edgeLoginUrl } from "../../http/edge.ts";
3030 import { sshCommand } from "../../ssh/command.ts";
3131 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
32 import { signupWindow } from "../../actions/signup-window.ts";
3233 import { RateLimiter } from "../../core/ratelimit.ts";
3334 import { field, readForm } from "../form.ts";
3435 import { passkeyJs } from "../passkey-client.ts";
@@ -458,29 +459,12 @@ export function signinAllowed(ctx: Ctx, username: string): number | undefined {
458459 }
459460
460461 /**
461 * Sign-ups, throttled the same way, by the socket's peer (`clientAddress`,
462 * which reads a forwarding header only from a proxy the operator trusts):
463 * a source may make a few accounts a minute and the forge as a whole more.
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.
462 * Sign-ups by the socket's peer (`actions/signup-window.ts`), asked by the
463 * signup form, the API signup and the terminal's create screen, which share
464 * one window; the passkey door follows (#2322).
466465 */
467 const signupLimits = new WeakMap<Ctx["app"], RateLimiter>();
468
469466 export function signupAllowed(ctx: Ctx): number | undefined {
470 let limiter = signupLimits.get(ctx.app);
471 if (limiter === undefined) {
472 limiter = new RateLimiter({ perMinute: 0, adminPerMinute: 0, enabled: true });
473 signupLimits.set(ctx.app, limiter);
474 }
475 limiter.setLimits({
476 perMinute: limitValue(ctx.app.db, "signup.per_source"),
477 adminPerMinute: limitValue(ctx.app.db, "signup.per_forge"),
478 enabled: true,
479 });
480 const one = limiter.take(`source:${bucketOf(clientAddress(ctx.request))}`, "ro");
481 if (!one.allowed) return one.retryAfter;
482 const whole = limiter.take("forge", "admin");
483 return whole.allowed ? undefined : whole.retryAfter;
467 return signupWindow(ctx.app, clientAddress(ctx.request));
484468 }
485469
486470 export async function signinSubmit(ctx: Ctx): Promise<Response> {
test/tui-signup-throttle.test.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * The terminal's create-account screen draws on the same signup windows as
3 * the other doors, keyed on the SSH peer's bucket: past signup.per_source it
4 * shows when to try again and makes no user.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { signUp } from "../src/actions/users.ts";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { notePeer } from "../src/http/peer.ts";
13 import { getUserByName } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import type { TuiIdentity } from "../src/tui/session.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { signupAllowed } from "../src/web/pages/site.ts";
18 import type { Ctx } from "../src/http/context.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 const KEY =
25 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo terminal-test";
26
27 describe("terminal sign-up throttle", () => {
28 let dir: string;
29 let forge: Forge;
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-tui-signup-"));
33 forge = createApp({ dataDir: dir, port: 0, disk: () => ({ freeBytes: 95, totalBytes: 100 }) });
34 const admin = await signUp(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
35 expect(admin).toMatchObject({ ok: true });
36 });
37 afterAll(async () => {
38 forge.close();
39 await rm(dir, { recursive: true, force: true });
40 });
41
42 const attempt = async (address: string, name: string): Promise<string> => {
43 const identity: TuiIdentity = { sshUsername: "git", remoteAddress: address, fingerprint: "SHA256:x", publicKey: KEY };
44 const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(120, 32), exitHooks: false });
45 app.start();
46 const key = (k: string, ctrl = false): void =>
47 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
48 const type = (t: string): void => [...t].forEach((c) => key(c));
49 try {
50 await settleApp(app);
51 key("s");
52 await settleApp(app);
53 key("t", true);
54 type(name);
55 key("tab");
56 type(`${name}@forge.test`);
57 key("tab");
58 type("a-long-passphrase");
59 key("enter");
60 await settleApp(app);
61 return app.screen.toText();
62 } finally {
63 app.stop();
64 }
65 };
66
67 test("past signup.per_source a terminal sign-up is refused with a wait and makes no user; an API-side call from that source shares the window", async () => {
68 const address = "203.0.113.50";
69 // Four sign-ups from this source through the shared window...
70 for (let i = 0; i < 4; i++) {
71 const request = new Request("http://forge.local/api/v1/users");
72 notePeer(request, address);
73 expect(signupAllowed({ app: forge, request } as unknown as Ctx)).toBeUndefined();
74 }
75 // ...the terminal gets the fifth...
76 await attempt(address, "fifth");

signupAllowed stands in for the API; main now has POST /api/v1/users calling the window, so exercise that route to prove the third door shares the count.

77 expect(getUserByName(forge.db, "fifth")).toBeTruthy();
78 // ...and the sixth is refused, with when to try again.
79 const text = await attempt(address, "sixth");
80 expect(text).toMatch(/Try again in \d+ seconds/);
81 expect(getUserByName(forge.db, "sixth")).toBeFalsy();
82 // The same source, by the web's door, is refused too.
83 const request = new Request("http://forge.local/signup");
84 notePeer(request, address);
85 expect(signupAllowed({ app: forge, request } as unknown as Ctx)).toBeGreaterThan(0);
86 // Another source is untouched.
87 await attempt("203.0.113.51", "other");
88 expect(getUserByName(forge.db, "other")).toBeTruthy();
89 });
90 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs