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

Merged opened this in answer to

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

Merged

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

opened this

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.ts now keys on account and source together. A stranger hammering admin fills only their own window, and the owner from another address still gets in.
  • M6, idempotency storage: the idempotency table no longer keeps token or secret fields (src/http/server.ts). That covers the token from POST /api/v1/tokens and a webhook's signing secret, in the same way claimUrl was already handled. The first caller still sees the secret. A replay gets "shown once, in the first answer".
  • L1, login CSRF: POST /api/v1/sessions now refuses a foreign Origin with 403 before it reads the body or sets a cookie (src/http/api.ts).
  • L3, markdown links: src/web/markdown.ts now refuses any link whose URL contains a control character. That closes \u0001javascript: and java\tscript:.
  • Tests: test/perimeter-mediums.test.ts has one test per fix.

Left out, on purpose

  • M2, anonymous spawn cap: the global cap and persisted counter for src/spawn/rate.ts are 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/sessions with Origin: https://evil.example and expect 403.

🤖 Generated with Claude Code

Closes #2922

commented

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.ts now keys on account and source together. A stranger hammering admin fills only their own window, and the owner from another address still gets in.
  • M6, idempotency storage: the idempotency table no longer keeps token or secret fields (src/http/server.ts). That covers the token from POST /api/v1/tokens and a webhook's signing secret, in the same way claimUrl was already handled. The first caller still sees the secret. A replay gets "shown once, in the first answer".
  • L1, login CSRF: POST /api/v1/sessions now refuses a foreign Origin with 403 before it reads the body or sets a cookie (src/http/api.ts).
  • L3, markdown links: src/web/markdown.ts now refuses any link whose URL contains a control character. That closes \u0001javascript: and java\tscript:.
  • Tests: test/perimeter-mediums.test.ts has one test per fix.

Left out, on purpose

  • M2, anonymous spawn cap: the global cap and persisted counter for src/spawn/rate.ts are 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/sessions with Origin: https://evil.example and expect 403.

🤖 Generated with Claude Code

Changes requested — before the latest push

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.

  1. M6 is only partly closed. Fix this first. POST /api/v1/admin/invites (src/http/api.ts:1660) answers with token and also link: inviteLink(origin, token). withoutShownOnce now redacts token, but link holds the same secret, so the idempotency table and its backups still keep a usable invite link. Add link to SHOWN_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 an Idempotency-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 calls withoutShownOnce on a hand-built object, so it would not catch a field that was missed.
  1. 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 Origin header, 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.ts and server.ts are 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.

commented

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. link is now in SHOWN_ONCE in src/http/server.ts, next to claimUrl, token and secret, 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.ts mints an invite with an Idempotency-Key over 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/tokens with 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 no Origin, both still sign in (201) and set an HttpOnly cookie. The foreign Origin is still refused with 403.
  • Docblock rewrapped. The long lines in signin-window.ts and the SHOWN_ONCE docblock in server.ts are 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

Changes requested — before the latest push

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_account limit in src/settings/limits.ts still 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/limits now 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 by signin.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.

commented

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, secret and link are stripped from what the idempotency table keeps and from replays, which now say "shown once". link is there because an invite link carries its token.
  • Login CSRF (L1): POST /api/v1/sessions refuses a foreign Origin with 403. The forge's own Origin and a request with no Origin still 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/limits text: signin.per_account is 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 an Idempotency-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. With secret taken out of the stripped list, this test fails.
  • Run here: bun test test/perimeter-mediums.test.ts passes, with the limits and admin suites. bun x tsc --noEmit is 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

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 bucketOf address. 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: withoutShownOnce only rewrites top-level keys. I checked every place in src/ that returns a top-level token, secret or link: 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: crossOrigin refuses a foreign Origin before the body is read or a cookie is set. A request with no Origin (curl, CLI) still works, which matches every other mutation.
  • L3: control characters are rejected before the scheme check, so \u0001javascript: and java\tscript: are no longer made links.
  • Tests: each of the four tests fails without its fix. bun test test/perimeter-mediums.test.ts at 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs