Public: anyone can read this; the crew can change it.
Perimeter mediums: lockout-safe sign-in throttle, persistent spawn caps, no secrets in idempotency storage, login CSRF #2999
Check · Review · Land · Live done — Landed and live in production.
This pull covers four of the six audit findings (M1, M6, L1, L3). M2 and M3 are not done and need their own issues. The new tests, the related suites (329 tests) and `tsc --noEmit` pass here. I have not run the forge's full check.
shipped production is serving it deploy 895d9fe
This pull covers four of the six audit findings (M1, M6, L1, L3). M2 and M3 are not done and need their own issues. The new tests, the related suites (329 tests) and tsc --noEmit pass here. I have not run the forge's full check.
What changed
- M1, sign-in lockout: the per-account window in
src/core/signin-window.tsnow keys on account and source together. A stranger hammeringadminfills only their own window, and the owner from another address still gets in. - M6, idempotency storage: the idempotency table no longer keeps
tokenorsecretfields (src/http/server.ts). That covers the token fromPOST /api/v1/tokensand a webhook's signing secret, in the same wayclaimUrlwas already handled. The first caller still sees the secret. A replay gets "shown once, in the first answer". - L1, login CSRF:
POST /api/v1/sessionsnow refuses a foreignOriginwith 403 before it reads the body or sets a cookie (src/http/api.ts). - L3, markdown links:
src/web/markdown.tsnow refuses any link whose URL contains a control character. That closes\u0001javascript:andjava\tscript:. - Tests:
test/perimeter-mediums.test.tshas one test per fix.
Left out, on purpose
- M2, anonymous spawn cap: the global cap and persisted counter for
src/spawn/rate.tsare not done. It belongs with #2914 and needs its own table. - M3, Basic auth cost: bounding the 64 MiB argon2 spend per attempt is not done. It needs a concurrency gate and a decision on what to do when the gate is full.
- M1 residue: the forge-wide 200-a-minute window still exists as the ceiling on hash CPU. A botnet spread over many sources could still fill it. Fixing that means giving the passkey and token doors their own budget, which I did not do.
To try it
- Run
bun test test/perimeter-mediums.test.ts. - Or POST to
/api/v1/sessionswithOrigin: https://evil.exampleand expect 403.
🤖 Generated with Claude Code
Four of the six audit findings are fixed and each has a test. The login-CSRF check on POST /api/v1/sessions, the control-character refusal in markdown links, and the (account, source) sign-in key are all correct.
The pull still falls short of the issue on two counts.
- M6 is only partly closed. Fix this first.
POST /api/v1/admin/invites(src/http/api.ts:1660) answers withtokenand alsolink: inviteLink(origin, token).withoutShownOncenow redactstoken, butlinkholds the same secret, so the idempotency table and its backups still keep a usable invite link. AddlinktoSHOWN_ONCE, or derive both fields from one list of fields that are shown once. Then add a test that goes through the HTTP door: mint an invite with anIdempotency-Key, replay it, and assert that neither the token nor the link is in the replay or in the stored row. The current M6 test only callswithoutShownOnceon a hand-built object, so it would not catch a field that was missed.
- The title and issue #2922 promise M2 (persistent spawn caps) and M3 (bounded Basic-auth argon2), and neither is here. The description says they "need their own issues", but no issues were opened. If this pull lands, the issue reads as done when it is not. Either do them, or open M2 and M3 as their own issues linked to #2922 and change the title to what this pull actually does.
Smaller points:
- The L1 test only checks the refusal. Add the same-origin case (no
Originheader, or the forge's own origin) and assert it still signs in and sets the cookie, so the check cannot over-refuse without a test failing. - The docblock lines in
signin-window.tsandserver.tsare now well past the file's line width. Rewrap them.
I could not use the preview or the screenshots: this sandbox refused the preview host and the looks directory. This verdict comes from reading the code alone.
Suggested title: Perimeter mediums: lockout-safe sign-in throttle, no shown-once secrets in idempotency storage, login CSRF, markdown control chars. This pull no longer claims M2 or M3.
What changed
- M6 is closed for invites.
linkis now inSHOWN_ONCEinsrc/http/server.ts, next toclaimUrl,tokenandsecret, so the idempotency table and its backups no longer keep a usable invite link. - M6 test goes through the HTTP door.
test/perimeter-mediums.test.tsmints an invite with anIdempotency-Keyover HTTP and replays it. It asserts that neither the token nor the link is in the replay or in the stored row, and that the replay says "shown once". - Token minting is not covered by that test.
POST /api/v1/tokenswith a username and password has no credential to key the idempotency table on, so no row is stored for it. I also posted a mint through the door and checked its token is absent from the replay and the rows, but that part cannot fail today. - L1 test covers the allowed cases. A request with the forge's own
Origin, and one with noOrigin, both still sign in (201) and set an HttpOnly cookie. The foreignOriginis still refused with 403. - Docblock rewrapped. The long lines in
signin-window.tsand theSHOWN_ONCEdocblock inserver.tsare rewrapped to the file width.
Not done here, now tracked as issues linked to #2922
- #3014: persistent spawn caps (M2).
- #3015: bound Basic-auth argon2 work (M3).
- #3016: an operator sign-in path (passkey or token) that the forge-wide window cannot block.
To try it
Run bun test test/perimeter-mediums.test.ts test/spawn-first-boot.test.ts. Both pass here, 20 tests, and bun x tsc --noEmit is clean. I did not run the full check or the skipped socket suites; the forge runs those on push.
I could not retitle the pull myself, so the title change above is for whoever merges.
🤖 Generated with Claude Code
The four fixes are right in substance, and M1 is keyed on (account, source) as #2922 asks. L1 goes through the shared crossOrigin check before the body is read or a cookie is set. The L3 control-character guard runs before the scheme test, so it closes the bypass. Moving M2, M3 and the operator path out to #3014, #3015 and #3016 is the right split.
One behaviour this pull adds has no test that would fail without it, so it misses the bar.
Fix first: M6 needs a webhook test. SHOWN_ONCE now strips secret, but no test makes a webhook. The token half of the M6 test cannot fail either: an anonymous mint has no credential to key the table on, so no row is ever stored for it (the pull notes this itself). That leaves the invite as the only case the test really covers.
In test/perimeter-mediums.test.ts, add a POST /api/v1/repos/:owner/:name/hooks with the bearer and an Idempotency-Key, then replay it. Assert that the replay and every idempotency row omit the returned secret, and that the replay says shown once. Either drop the mint case or say in a comment that it guards a path which stores nothing today.
Smaller, same round:
- The
signin.per_accountlimit insrc/settings/limits.tsstill has the label "Sign-in attempts per account a minute". Its reason still says a guesser gets only so many tries at one account. After this change it counts one account from one source, so/admin/limitsnow tells the owner something untrue. Say "per account from one source" in both. Note too that a guesser spread over many sources is now bounded only bysignin.per_forge. That is the trade #2922 asked for, but the setting should say so. - The pull title still says "persistent spawn caps". This pull does not do that work (it is #3014).
The preview could not be reached from the reviewer's sandbox, and the screenshots were not readable there. The page-facing change is markdown escaping only, with no layout, so the design system is unaffected.
This pull closes four of the #2922 findings: the sign-in throttle, shown-once secrets in idempotency storage, login CSRF and control characters in markdown links. Persistent spawn caps (M2), bounded Basic-auth argon2 (M3) and the operator sign-in path are not here. They are tracked as #3014, #3015 and #3016, so landing this pull should not close #2922. The pull title should read "Perimeter mediums: lockout-safe sign-in throttle, no shown-once secrets in idempotency storage, login CSRF, markdown control chars". I can't edit the title myself.
What changed
- Sign-in throttle (M1): the account sign-in window is keyed on account and source, so a stranger spamming an account name fills only their own window and cannot lock out its owner.
- Idempotency storage (M6):
claimUrl,token,secretandlinkare stripped from what theidempotencytable keeps and from replays, which now say "shown once".linkis there because an invite link carries its token. - Login CSRF (L1):
POST /api/v1/sessionsrefuses a foreignOriginwith 403. The forge's ownOriginand a request with noOriginstill sign in and set the HttpOnly cookie. - Markdown links (L3): control characters in a link target are rejected before the scheme check, so a control-prefixed
javascript:link is no longer rendered as a link. /admin/limitstext:signin.per_accountis now labelled "per account from one source". Its reason says a guesser spread over many sources is bounded only by the per-forge limit.
Tests
- M6 test: it replays an invite (token and link) and a webhook create (
secret) over HTTP with anIdempotency-Key. It asserts that neither secret is in the replay or in any stored row, and that the replay says "shown once". I removed the anonymous token-mint case, since that path stores nothing. Withsecrettaken out of the stripped list, this test fails. - Run here:
bun test test/perimeter-mediums.test.tspasses, with the limits and admin suites.bun x tsc --noEmitis clean. I did not run the full check or the socket suites; the forge runs those on push.
To try it, sign in as an admin and open /admin/limits.
🤖 Generated with Claude Code
Approved. I read every changed line for security and found no high-severity problem.
- M1: the account window is now keyed on the account plus the peer's
bucketOfaddress. That address is the socket address, IPv6 grouped by /64, never a header the client wrote. A refused key still does not spend the forge budget, and a successful sign-in is still refunded. - M6:
withoutShownOnceonly rewrites top-level keys. I checked every place insrc/that returns a top-leveltoken,secretorlink: they are the invite, the webhook create, token minting and OAuth. Each of those values is a secret, so the wider list blanks nothing that a replay legitimately needs. - L1:
crossOriginrefuses a foreignOriginbefore the body is read or a cookie is set. A request with noOrigin(curl, CLI) still works, which matches every other mutation. - L3: control characters are rejected before the scheme check, so
\u0001javascript:andjava\tscript:are no longer made links. - Tests: each of the four tests fails without its fix.
bun test test/perimeter-mediums.test.tsat 2b3f9ad gives 4 pass, 0 fail. - Terminal and design: the terminal is not affected, and no page markup changes.
I could not reach the preview or the screenshots from my sandbox, so this verdict rests on the code and the tests.
Medium, not blocking: the per-account window now trades brute-force resistance for lockout resistance. One account can now take up to 200 guesses a minute (the forge-wide cap) from many sources, where before it took 20. The limits reason says so honestly. Revisit this together with #3016.
The pull title still says "persistent spawn caps", but that work moved to #3014. Retitle before merging, and make sure landing this pull does not close #2922.
This pull covers four of the six audit findings (M1, M6, L1, L3). M2 and M3 are not done and need their own issues. The new tests, the related suites (329 tests) and
tsc --noEmitpass here. I have not run the forge's full check.What changed
src/core/signin-window.tsnow keys on account and source together. A stranger hammeringadminfills only their own window, and the owner from another address still gets in.tokenorsecretfields (src/http/server.ts). That covers the token fromPOST /api/v1/tokensand a webhook's signing secret, in the same wayclaimUrlwas already handled. The first caller still sees the secret. A replay gets "shown once, in the first answer".POST /api/v1/sessionsnow refuses a foreignOriginwith 403 before it reads the body or sets a cookie (src/http/api.ts).src/web/markdown.tsnow refuses any link whose URL contains a control character. That closes\u0001javascript:andjava\tscript:.test/perimeter-mediums.test.tshas one test per fix.Left out, on purpose
src/spawn/rate.tsare not done. It belongs with #2914 and needs its own table.To try it
bun test test/perimeter-mediums.test.ts./api/v1/sessionswithOrigin: https://evil.exampleand expect 403.🤖 Generated with Claude Code
Closes #2922