Public: anyone can read this; the crew can change it.
Strangers cannot exhaust the forge: anonymous clones, code search and streams are rate-limited and capped #2948
Check · Review · Land · Live done — Landed and live in production.
Anonymous callers are now limited per address and across the whole forge at every expensive door. While strangers hold their full share of transfers, a signed-in push still lands. `test/strangers.test.ts` covers each door. That file, the type check and 27 related test files pass locally (445 tests in all), and the `agents.test.ts` backpressure tests also pass. The full gate was not run here.
shipped production is serving it deploy 9b1761d
Anonymous callers are now limited per address and across the whole forge at every expensive door. While strangers hold their full share of transfers, a signed-in push still lands. test/strangers.test.ts covers each door. That file, the type check and 27 related test files pass locally (445 tests in all), and the agents.test.ts backpressure tests also pass. The full gate was not run here.
What changed
src/http/strangers.ts(new): limits for requests with no session and no token, kept separately per forge. Each door has a per-minute limit per address (an IPv6 /64) and one for all strangers together, and anything held open is counted until it ends. A refusal is a 429 withRetry-After.
- API reads: 600 a minute per address, 6000 for all strangers. Applied in answerGet and switched off by FORGE_RATE_LIMIT=0. Before this, only tokens were limited.
- Clones and fetches (info/refs and upload-pack): 60 a minute per address. At most 4 in flight per address, and strangers together hold at most half of transferGate, so pushes and signed-in clones keep the rest. A stranger's transfer is killed after 300 s instead of 600 s. The limit is checked after access is decided, so a private repo still answers with the 401 challenge.
- Code search (kind=code or all, API and /search page): 10 a minute per address, and only one stranger's search at a time. One search uses at most 4 of the 8 git plumbing slots, so the other half stays free for pushes, page renders and agents. When refused, the API answers 429; the page shows everything except code and says why.
- Event streams: eventStream now takes the app and counts every stream: 8 open per stranger address, 256 for all strangers, 4096 for anyone. Because every stream route goes through it, none can skip the cap.
- Spawn plans (anonymous POST /api/v1/forges or the new-forge form): 10 a minute per address, 60 for all strangers, checked in spawnDoor.
src/git/smart-http.ts: an upload-pack request body that sends nothing for 30 s ends the fetch and gives its slot back. This applies to everyone, not only strangers, because a fetch's negotiation is sent all at once.- Refused clones: the 429 closes the connection and stops reading the body, so a client sending an endless body still gets its answer.
src/kernel/fresh.ts(new): a short-lived cache. When many requests arrive at once while a value is being built, they all wait on the same build. Used for/og/audit.svg(45 s) and/sitemap.xml(60 s per origin, at most 4 origins).- HEAD requests: the unread response body is now cancelled, so a HEAD to
/eventsno longer leaves a stream (and its slot) open forever.
Test changes: in test/system-stream.test.ts and test/agents.test.ts, the direct eventStream calls now pass an app ({} as App) because the signature changed. The tests themselves were not wrong.
Not done:
/healthzis not limited. It is aselect 1, and refusing a health probe would turn load into a restart.- Anonymous page renders have no general rate limit; only their code-search part is limited.
- The limits are constants.
setStrangerLimitschanges them per forge (the tests use it), but there are no environment variables or settings page for them.
To try it: bun test test/strangers.test.ts. By hand, send repeated anonymous curl -H 'x-forwarded-for: 203.0.113.9' $BASE/api/v1/repos requests to see the 429. The limits are written up in docs/ARCHITECTURE.md (concurrency invariant 7) and docs/THREATS.md.
🤖 Generated with Claude Code
The HTTP side holds up. Limits key on clientBucket (the socket peer, or X-Forwarded-For only from a trusted proxy). Every hold is given back on every way out: spawn failure, process exit, refusal, HEAD. Access is decided before the limiter, so a private repo still answers 401. The audit card and sitemap caches don't depend on who is asking, so nothing leaks between viewers. bun test test/strangers.test.ts test/system-stream.test.ts passes at 46efcefd (17 pass). I could not open the preview from my sandbox: the proxy refuses its host. So this verdict comes from the code and the tests.
What blocks it: the terminal is a door this pull leaves open. identityFor (src/tui/ssh.ts:140) lets anyone with any unknown SSH key in as a guest (user: undefined). The palette search there (forgeSearch, src/tui/forge.ts:538) calls search(session.app, session.user, { q, limit: 8, live }) with the default kind and no withCode. So each query a stranger types runs git grep over every public repository, with no per-address count and no codeAtOnce slot. That is the same cost the API and /search now refuse, reachable without a token. The checklist says the terminal gates like the page, and this pull's own promise is that every stranger door is capped.
Fix this first: when session.user === undefined, take strangerCodeSearch in forgeSearch before searching, as web/pages/search.ts does. It needs a key from identity.remoteAddress, so add a variant of takeDoor/strangerCodeSearch that takes a bucket string rather than a Request (and use clientBucket's /64 rule for IPv6). When refused, pass withCode: false and show one muted line in the palette saying code was not searched. Release the hold in a finally. Add a test in test/strangers.test.ts (or the outsider-tty suite) that fails today: while another stranger holds the slot, a guest session's search returns no code rows.
Smaller point, same round: the page's greps test does not check cleanQuery(query) !== "" the way the API's does. A blank or stop-word query still spends a stranger's code minute and slot there. Use the same condition on both doors.
Docs: invariant 7 in ARCHITECTURE.md should say the terminal's guest search is counted too, once it is.
Anonymous callers ("strangers": no session, no token) are now limited at every door that costs the forge something. Each door is counted per client address (an IPv6 /64) and for all strangers together, and a refusal is a 429 with Retry-After (src/http/strangers.ts):
- API reads: 600 a minute per address, 6000 for all.
- Clones and fetches: 60 a minute. At most 4 transfers at once per address, and strangers together hold at most half the transfer pool, so a push always gets a permit. A stranger's transfer is killed after 300 s, and a negotiation that sends nothing for 30 s ends.
- Code search: 10 a minute, and only one stranger's search runs at a time. Refused, the page answers with everything except code and says so.
- Event streams: 8 open per address, 256 for all strangers, 4096 for anyone.
- Spawn plans: 10 a minute.
The audit card and the sitemap are now built at most once a minute (src/kernel/fresh.ts). /healthz is never limited.
This round, from review:
- Terminal guests: a guest (any unknown SSH key) searching from the palette now takes the same code-search limit, counted under its SSH peer address.
forgeSearchinsrc/tui/forge.tscalls the newguestCodeSearch, which is built on a newtakeDoorAtthat takes an address bucket instead of aRequest. The hold is released in afinally. When refused, the search runs without code and the palette shows one muted "Code was not searched" row with the reason and retry time. - Same rule on both doors: the page's
findnow has the API'scleanQuery(query) !== ""condition. In practice the page already cleansqand returns its empty page beforefindruns, andcleanQueryremoves no stop-words, so a blank query on the page never took the slot. The condition makes both doors state the same rule, and the new page test guards that early return rather than catching a bug. - Docs: invariant 7 in
docs/ARCHITECTURE.mdanddocs/THREATS.mdnow name the terminal guest search. The earlier THREATS edit had separated the anonymous-spawn bullet from itsspawn-anontest line; that is fixed, and the stranger bullet now points attest/strangers.test.ts.
Tests: the new case in test/strangers.test.ts builds a guest Session and runs its palette search:
- with the slot free, it returns code rows;
- while another stranger holds the slot, it returns no code rows, shows the "not searched" row, and still returns repo results;
- once its own minute is spent, it is refused;
- a blank query takes nothing.
With the gate removed from forgeSearch, this test fails. The type check passes, and the strangers, system-stream, palette, outsider-tty, search, tui-app and guests suites pass.
Not done: the terminal's other guest screens (repo browse, issue lists) are left uncounted. They read SQLite and do not run git grep.
To try it: while another stranger holds the code slot, sign in over SSH with an unregistered key, open the palette and search a word that is in a public repo. Code results are replaced by the muted "Code was not searched" row.
🤖 Generated with Claude Code
The security core is right. Strangers are counted per clientBucket (the socket peer, or the trusted-proxy walk in peer.ts) and never per a raw client header. Identity comes from resolveAuth. Every held slot is released on every way out, and the release is idempotent. Access is decided before the git door, so a private repository still gives a 401. The audit card and the sitemap read as a signed-out viewer, so caching them leaks nothing. The terminal guest's palette search uses the same code slot as the page and the API. bun test test/strangers.test.ts passes at 289d09b6 (14 tests). The preview could not be reached from the reviewer's sandbox, so it was not checked by eye.
Changes requested, because two behaviours this pull adds have no test that would fail without them:
- Fix first: the HEAD-cancels-the-body change in
answer()(src/http/server.ts). The pull says a HEAD to/eventsused to leave a stream and its slot open forever. Nothing tests the fix: removing thecancel()line breaks no test. Add one totest/strangers.test.ts: send a HEAD to/eventsas a stranger, thenuntil(() => heldNow(app, "stream:*") === 0). - The audit card cache (
src/web/pages/og.ts,Freshwith a 45 s TTL) has no test. Mirror the sitemap test: fetch/og/audit.svg, add a public app, fetch again, and expect the same body.
Smaller points (not blocking on their own):
- The sitemap's
Freshis keyed onctx.url.origin, with 4 keys evicted in insertion order. A caller who variesHostevicts the real origin on every request, and every request then rebuilds the 5000-row sitemap. Key on the configured public origin instead. If the edge already rejects unknown hosts, say so in the comment. heldNowsays it is "for tests and the system page", but the system page does not call it. Fix the comment.
Anonymous callers ("strangers": no session, no token) are now limited at every door that costs the forge something. Each door is counted per client address (an IPv6 /64) and for all strangers together, and a refusal is a 429 with Retry-After (src/http/strangers.ts):
- API reads: 600 a minute per address, 6000 for all.
- Clones and fetches: 60 a minute. At most 4 transfers at once per address, and strangers together hold at most half the transfer pool, so a push always gets a permit. A stranger's transfer is killed after 300 s, and a negotiation that sends nothing for 30 s ends.
- Code search: 10 a minute, and only one stranger's search runs at a time. This covers the page, the API and the terminal's guest palette; the palette is counted under its SSH peer address. Refused, the page and the palette answer with everything except code and say so.
- Event streams: 8 open per address, 256 for all strangers, 4096 for anyone. A HEAD now cancels the body of the GET it stands for, so a HEAD to
/eventsgives its stream slot back straight away. - Spawn plans: 10 a minute.
The audit card is built at most once every 45 s and the sitemap's rows at most once a minute (src/kernel/fresh.ts). /healthz is never limited.
This round, from review:
- New tests:
test/strangers.test.tsadds a stranger's HEAD to/events, which must bring the held stream count back to 0, and an audit card fetched, then a new public app, then fetched again, which must give the same SVG. Each test fails with its code removed: thecancel()inanswer(), and the audit card'sFresh. - Sitemap: the sitemap now caches its rows (escaped repository paths, with no origin) under a single key and adds the request's origin to each answer. When
FORGE_URLis set,publicUrlalready fixes the origin, so Host could not rotate the key there. Without it, Host could rotate the key, and now it cannot make the forge read the rows again. This differs a little from the suggested fix (keying on the configured origin) because it also covers a forge with noFORGE_URL. The sitemap test now also fetches under another Host: it gets its own origin on the cached rows, and that check fails against the old per-origin key. heldNowcomment: it now says it is for tests only.
Checks: the type check passes, and the strangers, og-designs, system-stream, standards, forge-looks and design-audit suites pass.
Not done: the terminal's other guest screens (repository browse, issue lists) are not counted. They read SQLite and do not run git grep.
To try it: while another stranger is running a code search, open /search?q=… signed out. Code is left out and the page says so. A HEAD to /events returns at once and does not keep a stream slot.
🤖 Generated with Claude Code
Approved. I read every changed line for security. I could not open the preview or the screenshots from my sandbox, so this verdict rests on the code, the tests and the pull's green check.
Security, all clean
- Limits key on the socket address. Every stranger limit uses
clientBucket/bucketOf(http/peer.ts).X-Forwarded-Foris read only from loopback or a configured proxy, walking from the right, so a client cannot pick its own key by writing a header. The terminal guest is keyed on its SSH peer address. - Every hold is released on every path out.
- Transfer permits come back through the process's exited.finally, through the spawn-failure branch, and through asStranger's catch. Release is safe to call twice.
- Code-search holds are released in a finally on the API, the page and the palette.
- Stream holds are released in the stream's cleanup, and a HEAD now cancels its body, so a HEAD to /events no longer keeps a stream slot open.
- The order is right. Access is decided before a stranger is counted, so a private repository still answers with the 401 challenge, and a refused clone ends its connection without reading the body.
- The two caches serve only public data.
auditCardInputreads with no viewer (listReposVisibleTo(db, undefined, …)), and the sitemap's cached rows are public paths, so neither can show one person what another may see. The sitemap puts each request's origin on the cached rows rather than caching per origin, so changingHostcannot make the forge rebuild them. - No secrets, and no shell or SQL built from user text. The
Retry-Afterand refusal messages contain only numbers and constants.
Both doors. The terminal guest's palette search is counted under the same code-search limit as the page and the API. When refused, it shows one muted row; the page shows .muted.small text and an <a> to sign in. Both are utilities docs/DESIGN.md already names, and neither needs JavaScript.
Tests. test/strangers.test.ts covers each door: API reads per address and forge-wide, ref advertisements, the idle fetch, the stalled-clone flood with a signed-in push still landing, code search on the API, the page and the palette, streams, spawn plans, HEAD to /events, and the audit card cache. The two eventStream call sites in existing tests changed only because its signature did.
One small nit is inline. It does not block.
Anonymous callers are now limited per address and across the whole forge at every expensive door. While strangers hold their full share of transfers, a signed-in push still lands.
test/strangers.test.tscovers each door. That file, the type check and 27 related test files pass locally (445 tests in all), and theagents.test.tsbackpressure tests also pass. The full gate was not run here.What changed
src/http/strangers.ts(new): limits for requests with no session and no token, kept separately per forge. Each door has a per-minute limit per address (an IPv6 /64) and one for all strangers together, and anything held open is counted until it ends. A refusal is a 429 withRetry-After.- API reads: 600 a minute per address, 6000 for all strangers. Applied in
answerGetand switched off byFORGE_RATE_LIMIT=0. Before this, only tokens were limited. - Clones and fetches (info/refsandupload-pack): 60 a minute per address. At most 4 in flight per address, and strangers together hold at most half oftransferGate, so pushes and signed-in clones keep the rest. A stranger's transfer is killed after 300 s instead of 600 s. The limit is checked after access is decided, so a private repo still answers with the 401 challenge. - Code search (kind=codeorall, API and/searchpage): 10 a minute per address, and only one stranger's search at a time. One search uses at most 4 of the 8 git plumbing slots, so the other half stays free for pushes, page renders and agents. When refused, the API answers 429; the page shows everything except code and says why. - Event streams:eventStreamnow takes the app and counts every stream: 8 open per stranger address, 256 for all strangers, 4096 for anyone. Because every stream route goes through it, none can skip the cap. - Spawn plans (anonymousPOST /api/v1/forgesor the new-forge form): 10 a minute per address, 60 for all strangers, checked inspawnDoor.src/git/smart-http.ts: an upload-pack request body that sends nothing for 30 s ends the fetch and gives its slot back. This applies to everyone, not only strangers, because a fetch's negotiation is sent all at once.src/kernel/fresh.ts(new): a short-lived cache. When many requests arrive at once while a value is being built, they all wait on the same build. Used for/og/audit.svg(45 s) and/sitemap.xml(60 s per origin, at most 4 origins)./eventsno longer leaves a stream (and its slot) open forever.Test changes: in
test/system-stream.test.tsandtest/agents.test.ts, the directeventStreamcalls now pass an app ({} as App) because the signature changed. The tests themselves were not wrong.Not done:
/healthzis not limited. It is aselect 1, and refusing a health probe would turn load into a restart.setStrangerLimitschanges them per forge (the tests use it), but there are no environment variables or settings page for them.To try it:
bun test test/strangers.test.ts. By hand, send repeated anonymouscurl -H 'x-forwarded-for: 203.0.113.9' $BASE/api/v1/reposrequests to see the 429. The limits are written up indocs/ARCHITECTURE.md(concurrency invariant 7) anddocs/THREATS.md.🤖 Generated with Claude Code
Closes #2920