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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2374-the-terminal-s-create-account-screen-goe → main 4 commits fork 2 threads · 2 unresolved
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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { SIGNUP_CLOSED_MESSAGE, signUp, signupClosed } from "../../actions/users.ts"; |
| 12 | import { signupWindow, waitText } from "../../actions/signup-window.ts"; | |
| 12 | 13 | import { hasAnyUser } from "../../core/users.ts"; |
| 13 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 15 | import { binding } from "../app/keys.ts"; |
| @@ -66,6 +67,11 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 66 | 67 | string, |
| 67 | 68 | string, |
| 68 | 69 | ]; |
| 70 | const wait = signupWindow(f.app, f.identity.remoteAddress); | |
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 | } | |
| 69 | 75 | const created = await signUp( |
| 70 | 76 | f.app, |
| 71 | 77 | { username, email, password }, |
src/web/pages/site.ts +6 −22
| @@ -12,7 +12,7 @@ import { NOT_A_MEMBER } from "../../core/roles.ts"; | ||
| 12 | 12 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 13 | 13 | import { limitValue } from "../../settings/limits.ts"; |
| 14 | 14 | import { signOutHere } from "../../actions/signins.ts"; |
| 15 | import { bucketOf, clientAddress } from "../../http/peer.ts"; | |
| 15 | import { clientAddress } from "../../http/peer.ts"; | |
| 16 | 16 | import { verifyEmail } from "../../actions/verify.ts"; |
| 17 | 17 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 18 | 18 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; |
| @@ -29,6 +29,7 @@ import { homeTop } from "../studio/asks_page.ts"; | ||
| 29 | 29 | import { edgeLoginUrl } from "../../http/edge.ts"; |
| 30 | 30 | import { sshCommand } from "../../ssh/command.ts"; |
| 31 | 31 | import { hostOf, pageHead, signInFirst } from "../chrome.ts"; |
| 32 | import { signupWindow } from "../../actions/signup-window.ts"; | |
| 32 | 33 | import { RateLimiter } from "../../core/ratelimit.ts"; |
| 33 | 34 | import { field, readForm } from "../form.ts"; |
| 34 | 35 | import { passkeyJs } from "../passkey-client.ts"; |
| @@ -458,29 +459,12 @@ export function signinAllowed(ctx: Ctx, username: string): number | undefined { | ||
| 458 | 459 | } |
| 459 | 460 | |
| 460 | 461 | /** |
| 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). | |
| 466 | 465 | */ |
| 467 | const signupLimits = new WeakMap<Ctx["app"], RateLimiter>(); | |
| 468 | ||
| 469 | 466 | 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)); | |
| 484 | 468 | } |
| 485 | 469 | |
| 486 | 470 | 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 | }); |
new above
offline
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.