/login/invite/accept and forge user accept checked the key before the code, so anyone could ask whether a key had an account here ("that key is already on this forge"). The code is checked first now, on the web and in the shell, and without a live invite every post gets the same expired answer, whatever its key. The rate limit was one bucket for the whole forge that every post counted against, so junk posts could use up an invitee's turns: posts are counted per address now, 10 a minute, and only those with a live invite's code. Scenarios: a dead code answers the same for any key, and the per-address, live-only count.- commit
- 473a10f84180dbd2c0dc654aa8e72a2d525c9a7f
- parent
- 2133113aac28d246e42c834ac8d1019d52b5d732
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 118s, signed by cat
- kind
- fix risk score 1.8 of 3, Jev's estimate
- review
- #30 Security: sign-in, invites, owner pages, lake, caps and the edge
ci attestation
forge-attestation 1 type ci commit 473a10f84180dbd2c0dc654aa8e72a2d525c9a7f tree bec2e222de26eabd889ce8cd76f875a9728fd18d script .forge/ci 8d2000a416f8160bcf710d98b72af9eb76287f58 result pass exit 0 started 2026-10-19T07:46:28Z duration 118s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:45ff5f20e2fa5bd0fd9777716e76d514b0581d1dcd5b57b209822e6d19711e78 318 lines |pushed wip/i18n |c3c5e47 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |c3c5e47 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |e368313 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |e368313 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |1bac5e0 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |1bac5e0 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 10999ed -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |cfe84f8 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |cfe84f8 pass in 0s, signed |pushed main |32ae0a5 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |32ae0a5 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 4104c25 -> /sites/dami/forge/guide/ |(node:87972) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www |(node:88133) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www |(node:88305) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www |(node:88472) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www |(node:89178) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www |235 of 235 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | README.md | 9 | +++++--- |
| M | bin/ | 5 | ++++- |
| M | lib/ | 9 | ++++++-- |
| M | lib/ | 34 | ++++++++++++++---------------- |
| A | test/ | 30 | ++++++++++++++++++++++++++ |
| A | test/ | 24 | +++++++++++++++++++++ |
@@ -171,9 +171,12 @@ in keys, the home directory, `keys sync`. With `--repo`, the new user is
171171 that repo's reader or collaborator too. Then the same page offers to add a172172 passkey, as the link `ssh passkey` prints does. The code is in the URL's173173 fragment and identity.db keeps only its hash, like enrollment codes. The174-posts come only from the forge's own page (Origin), at most 30 a minute175-across the forge, one taking a key at a time. `user add` checks a key the176-same way, so no key adds a second line to keys.174+posts come only from the forge's own page (Origin), at most 10 a minute from175+one address with a live invite's code, one taking a key at a time. The code176+is checked first: without a live invite every post gets the same answer,177+whatever its key, so the page can't be asked whether a key has an account,178+and posts with dead codes aren't counted. `user add` checks a key the same179+way, so no key adds a second line to keys.177180 178181 #### Admins179182 @@ -1365,7 +1365,9 @@ user_invite() {
13651365 }13661366 13671367 # user accept: an invite's code and an SSH public key, on stdin, a line1368-# each, from /login/ (lib/private-server.ts). The key is checked before the1368+# each, from /login/ (lib/private-server.ts). The code is checked first:1369+# without a live invite, every key gets the same refusal, so nobody learns1370+# from it whether a key is on this forge. The key is checked before the13691371 # invite is used up, so a bad one leaves the link working; then the name13701372 # gets it as forge user add gives one, and the invite's repo, if any. Prints13711373 # the new user's name.@@ -1373,6 +1375,7 @@ user_accept() {
13731375 load_config13741376 IFS= read -r code || die "usage: forge user accept (code and key on stdin)"13751377 key=$(cat)1378+ node "$FORGE_HOME/lib/identity.ts" invitation "$code" || exit 113761379 key_ok "$key" || die "not an SSH public key (one line: ssh-ed25519 AAAA... [comment])"13771380 o=$(key_owner "$key")13781381 [ -z "$o" ] || die "that key is already on this forge"@@ -655,6 +655,11 @@ function operatorCommand(state: string, cmd: string, args: string[], out: (s: st
655655 case 'invited':656656 if (args.length !== 1) fail('usage: invited <name>');657657 return id.invited(args[0]) ? 0 : 1;658+ case 'invitation':659+ // Is the code a live invite's? Without using it up.660+ if (args.length !== 1) fail('usage: invitation <code>');661+ if (!id.invitation(args[0])) fail(INVITE_EXPIRED);662+ return 0;658663 case 'accept': {659664 if (args.length !== 1) fail('usage: accept <code>');660665 const i = id.accept(args[0]);@@ -695,12 +700,12 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
695700 const r = Identity.check(args[0]);696701 if (r !== 'ok') fail(`${args[0]}: ${r}`);697702 }698- else if (['invite', 'invites', 'invited', 'accept', 'users'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));703+ else if (['invite', 'invites', 'invited', 'invitation', 'accept', 'users'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));699704 else if (['passkey', 'passkeys', 'sessions', 'token', 'tokens'].includes(cmd)) {700705 const user = process.env.FORGE_USER;701706 if (!user) fail('no FORGE_USER');702707 sshCommand(state, user!, cmd, args, (s) => process.stdout.write(s), (s) => process.stderr.write(s));703- } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|accept|users ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');708+ } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|invitation|accept|users ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');704709 } catch (e) {705710 if (!(e instanceof Usage)) throw e;706711 process.stderr.write(`forge: ${e.message}\n`);@@ -187,10 +187,12 @@ function forgeCmd(cfg: PrivateConfig, args: string[], input: string, extra: Node
187187 // What bin/forge said it refused: its "forge: " lines.188188 const refusal = (err: string, otherwise: string): string => err.split('\n').filter((l) => l.startsWith('forge: ')).map((l) => l.slice(7)).join('\n') || otherwise;189189 190-// Invites: the forge as a whole takes at most this many posts a minute191-// (the codes can't be guessed; this keeps anyone from trying), and one192-// accept at a time, since each writes .forge/keys.193-const INVITE_POSTS_PER_MINUTE = 30;190+// Invites: each address may post this many times a minute with a live191+// invite's code, and one accept runs at a time, since each writes192+// .forge/keys. A dead code (the codes can't be guessed) costs a lookup and193+// gets the same answer whatever was wrong, so it isn't counted: nobody can194+// use up an invitee's turns by posting junk.195+const INVITE_POSTS_PER_MINUTE = 10;194196 // Sign-in options: each address may ask this many times a minute.195197 const OPTIONS_PER_MINUTE = 20;196198 @@ -405,23 +407,19 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
405407 // checks and adds the way forge user add does, using the invite up, and406408 // answers with a code for the passkey enrollment that follows on the407409 // same page (the one ssh passkey would print). Nobody is signed in by it.408- const invitePosts: number[] = [];410+ // The code is checked first: without a live invite every post gets the411+ // same answer, whatever its key, so nothing is learned about keys.412+ const invitePosts = new Window(INVITE_POSTS_PER_MINUTE, 60_000);409413 let accepting = false;410- async function invite(res: http.ServerResponse, what: 'options' | 'accept', posted: unknown): Promise<void> {414+ async function invite(req: http.IncomingMessage, res: http.ServerResponse, what: 'options' | 'accept', posted: unknown): Promise<void> {411415 const body = (posted && typeof posted === 'object' ? posted : {}) as Record<string, unknown>;412- const t = Date.now();413- while (invitePosts.length && t - invitePosts[0] >= 60_000) invitePosts.shift();414- if (invitePosts.length >= INVITE_POSTS_PER_MINUTE) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });415- invitePosts.push(t);416416 const code = typeof body.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.code) ? body.code : '';417- if (what === 'options') {418- const i = code ? gate.id.invitation(code) : null;419- if (!i) return json(res, 400, { error: INVITE_EXPIRED, expired: true });420- return json(res, 200, { user: i.user, repo: i.repo, role: i.role });421- }417+ const i = code ? gate.id.invitation(code) : null;418+ if (!i) return json(res, 400, { error: INVITE_EXPIRED, expired: true });419+ if (!invitePosts.take(clientIp(req))) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });420+ if (what === 'options') return json(res, 200, { user: i.user, repo: i.repo, role: i.role });422421 const key = typeof body.key === 'string' ? body.key.trim() : '';423422 if (!key || key.length > 8192 || /[\r\n]/.test(key)) return json(res, 400, { error: 'Paste one SSH public key: the one line of your .pub file' });424- if (!code) return json(res, 400, { error: INVITE_EXPIRED, expired: true });425423 if (accepting) return json(res, 503, { error: 'The forge is busy: try again in a moment' }, { 'Retry-After': '5' });426424 accepting = true;427425 let r;@@ -471,8 +469,8 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
471469 const { user, passkey } = await login.loginVerify(body, browser);472470 return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));473471 }474- if (url.pathname === '/login/invite/options') return await invite(res, 'options', body);475- if (url.pathname === '/login/invite/accept') return await invite(res, 'accept', body);472+ if (url.pathname === '/login/invite/options') return await invite(req, res, 'options', body);473+ if (url.pathname === '/login/invite/accept') return await invite(req, res, 'accept', body);476474 if (url.pathname === '/login/enroll/options') return await options((b) => login.enrollOptions(body.code, b));477475 if (url.pathname === '/login/enroll/verify') {478476 const { user, passkey } = await login.enrollVerify(body.code, body.response, agentName(req.headers['user-agent']), browser);@@ -0,0 +1,30 @@
1+# Without a live invite, posting a key to /login/invite/accept gets the same2+# answer whatever the key: one already on the forge, a new one or not a key3+# at all. So the page can't be asked whether a key has an account. The4+# shell side (forge user accept) checks the code first too.5+. "$(dirname "$0")/../lib.sh"6+user cat7+export FORGE_BASE_URL=https://forge.test8+private_server9+ssh-keygen -q -t ed25519 -N '' -C fox -f "$T/keys/fox"10+accept() {11+ node -e 'console.log(JSON.stringify({ code: process.argv[1], key: process.argv[2] }))' -- "$1" "$2" >"$T/body"12+ http -s -i -H 'Origin: https://forge.test' -H 'Content-Type: application/json' -d @"$T/body" "$PRIV/login/invite/accept" | grep -av '^Date: '13+}14+accept nonsense "$(cat "$T/keys/cat.pub")" >"$T/known"15+has "$T/known" '^HTTP/1.1 400 '16+has "$T/known" 'expired, been used or been taken back'17+lacks "$T/known" 'already on this forge'18+for key in "$(cat "$T/keys/fox.pub")" hello ""; do19+ accept nonsense "$key" >"$T/other"20+ cmp -s "$T/known" "$T/other" || { diff "$T/known" "$T/other" >&2 || true; fail "a dead invite answers differently for key '$key'"; }21+done22+# A code that was live and is used up answers the same.23+ok forge user invite fox24+code=$(sed -n 's/^ .*#invite=//p' "$T_LOG")25+ok forge user invites rm fox26+accept "$code" "$(cat "$T/keys/cat.pub")" >"$T/other"27+cmp -s "$T/known" "$T/other" || fail "a taken-back invite answers differently"28+printf '%s\n%s\n' nonsense "$(cat "$T/keys/cat.pub")" | forge user accept >"$T_LOG" 2>&1 && fail "user accept took a dead code"29+has "$T_LOG" 'expired, been used or been taken back'30+lacks "$T_LOG" 'already on this forge'@@ -0,0 +1,24 @@
1+# Invite posts are counted per address, and only those with a live invite's2+# code: 10 a minute, then a 429. Junk codes, however many, are never3+# counted, so they can't use up an invitee's turns, and another address4+# has its own count.5+. "$(dirname "$0")/../lib.sh"6+user cat7+export FORGE_BASE_URL=https://forge.test8+private_server9+ok forge user invite fox10+code=$(sed -n 's/^ .*#invite=//p' "$T_LOG")11+ask() { http -s -o /dev/null -w '%{http_code}' -H 'Origin: https://forge.test' -H 'Content-Type: application/json' -H "X-Forge-Client-IP: $1" -d "{\"code\":\"$2\"}" "$PRIV/login/invite/options"; }12+n=013+while [ "$n" -lt 40 ]; do14+ eq "$(ask 192.0.2.9 "junk$n")" 40015+ n=$((n + 1))16+done17+n=018+while [ "$n" -lt 10 ]; do19+ eq "$(ask 192.0.2.9 "$code")" 20020+ n=$((n + 1))21+done22+eq "$(ask 192.0.2.9 "$code")" 42923+eq "$(ask 192.0.2.9 junk)" 40024+eq "$(ask 192.0.2.10 "$code")" 200