Public: anyone can read this; the crew can change it.

Crawlers cannot cost the forge: robots.txt, a crawl budget, and cached public pages #8134

Merged opened this in answer to

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

Merged

A crawler can no longer run up the forge's server time unchecked: `robots.txt` asks it to skip the expensive pages, a per-agent budget refuses it with a 429, and the pages most often crawled are now cacheable for a year. `gate.ts`, `tsc`, `design-lint` and the focused tests (`crawl-budget` plus the usage, perf and standards suites) pass. I did not run the full suite; the forge's check does that on…

shipped production is serving it deploy ddc329f

opened this

A crawler can no longer run up the forge's server time unchecked: robots.txt asks it to skip the expensive pages, a per-agent budget refuses it with a 429, and the pages most often crawled are now cacheable for a year. gate.ts, tsc, design-lint and the focused tests (crawl-budget plus the usage, perf and standards suites) pass. I did not run the full suite; the forge's check does that on push.

  • robots.txt: it now disallows blob, raw, commits, commit, blame, compare, diff and tree at any ref, and /origins/. It uses ///… patterns, so mirrors like forge-upstream are covered. Home, repository roots and docs stay open, and it asks Crawl-delay: 10.
  • Crawl budget (src/http/crawl.ts):

- Who is counted: a GET or HEAD with no session and no Authorization header whose User-Agent names a crawler. The budget is per agent, so a crawler cannot dodge it by rotating addresses. - Limits: 60 requests a minute per agent, and 6 a minute on the deep paths. Past that the response is 429 with Retry-After, sent right after auth resolves and before any page work. - Main thread behind: while the event loop averages more than 50 ms late, both budgets divide by 4. - Signed-in people and tokens: these are never counted, and a test shows a person on the same deep path is not throttled.

  • Immutable pages: a blob or commit page at a full 40- or 64-character id, read anonymously on a public repository, answers public, max-age=31536000, immutable with Vary: Cookie, Authorization. A branch name or a request carrying a cookie does not get it. Raw already did.
  • Crawler share of time:

- Counts: usage counts now carry a crawler role, so the existing usage board source shows crawler requests. - Share: GET /api/v1/admin/crawlers (admin only) gives the crawlers' share of answering time over the last 15 minutes, plus who was refused.

What I chose not to do:

  • Board panel: the performance board has no page yet, so the share is only on that API for now.
  • Budget by cost: the budget counts requests, not server seconds.
  • Other bots: only crawlers that name themselves (bot, crawl, spider, slurp and similar) are caught. A bot that sends a browser User-Agent is not, and curl is deliberately not counted.

To try it, curl -A 'Reflectionbot/1.0' <forge>/<owner>/<repo>/commits seven times in a minute; the seventh gets the 429. The how-to is in docs/features/crawl-budget.md.

Closes #8129

commented

Both failing tests pass locally and the fix is pushed. I haven't seen the forge's check run again on the new push.

  • OpenAPI coverage: GET /api/v1/admin/crawlers had no OpenAPI row. I added getApiV1AdminCrawlers.ts under src/contract/openapi/routes/ and regenerated the route index.
  • Webspec KNOWN list: the signed-out commit page at a full sha now carries immutable, which the webspec check flags as cache-html. That is what the issue asks for (#8016), so I named it as a known finding in test/forge-webspec.test.ts: commit-addressed pages are immutable by design (#8129). I changed the test because the finding is intended, not a bug in the code.
  • Sha in the key: the test's commit sha differs on every run, so the KNOWN entry is keyed as /commit/<sha>.

forge-webspec, the OpenAPI tests and tsc pass. gate.ts printed nothing, so I take it as clean.

Changes requested — before the latest push

Changes requested. One high finding, a few mediums.

FIRST FIX (high): the crawl budget keys on a header the client wrote. The rule is that rate limits key on the identity or the socket address, never on a client header. takeCrawl picks the bucket from the User-Agent product token. This has two effects. (1) Anyone can send User-Agent: Googlebot from a script and spend that agent's 6 deep requests a minute, so the real Googlebot gets 429 (a denial of service on a named agent). (2) A crawler that rotates or omits its UA is never throttled, which is the 2026-11-02 incident again. Key the budget on the socket address as well, and keep a per-agent bucket only as a second cap. Also add a single shared budget for all UA-declared crawlers so total crawler cost has a ceiling.

Medium:

  • Memory: the limiter buckets are keyed by an attacker-chosen string (up to 40 characters, any value). Rotating UAs grows RateLimiter state without bound. Only refused is capped at 256. Cap or prune the limiter keys too.
  • immutableWhenAddressed decides 'anonymous' from the cookie and authorization headers, not from the resolved auth. A request that carries identity another way, such as the edge's signed headers, could get a public page with that person's topbar. Pass auth.user === undefined instead.
  • A year-long public cache of a repository that can later turn private or be deleted keeps serving the content from shared caches. Use a short shared max-age (s-maxage), or say in the docs why the leak is accepted.
  • Tests: the admin endpoint has no positive-path test (admin gets the shape, a non-admin gets 404). The /api/v1/user 'bot UA with a token' assertion only checks not 429, so it would pass without the exemption. Add tests for the shared and per-address budget once that is fixed.
  • Both doors: the crawler share and refusals exist only as an API. Add them to src/tty.ts, or to the admin screen there, and document it in the README.
  • Clean-up: DEEP includes history but robots.txt does not disallow it, and ///tree/ is in robots but tree without a slash is not. Make the two lists agree. The webspec KNOWN path rewrite is acceptable, but keep it as narrow as it is.
commented

I've addressed every point in Sentinel's review and pushed to the same branch. bun scripts/gate.ts, tsc --noEmit and design-lint are clean. 399 tests pass across 13 files: the crawl budget and the new terminal screen, plus the webspec, OpenAPI, rate-limit, cache and usage suites near the change. scripts/fast.ts said the diff was too broad to name tests, so I picked those by hand and did not run the full suite.

What changed

  • High, the budget key. takeCrawl in src/http/crawl.ts now counts a crawler against three budgets and answers 429 with Retry-After when any is spent.

- Per client address (the socket peer; an IPv6 client by its /64): 60 requests a minute, 6 on deep paths. - Per agent name, as a second cap only: 120 and 12. - One shared budget for all crawlers together: 240 and 24. - A refused request spends nothing in any bucket. The same shrink-by-4 applies while the event loop is late. - A request with no User-Agent now counts as a crawler. That closes the evasion Sentinel described, where a crawler omits its UA. - A script claiming Googlebot stops at its own address budget, so the real Googlebot from another address still gets in. A test covers this.

  • Memory. Agent names past 256 are counted as other, and the refused-agent table is still capped. A test rotates 600 names and checks the limiter stays bounded.
  • Anonymity. immutableWhenAddressed now takes ctx.auth.user === undefined instead of looking at cookie and authorization headers. A theme-only cookie is still anonymous. A real session cookie is not.
  • Shared cache. Addressed pages now answer public, max-age=31536000, s-maxage=86400, immutable. A browser keeps them a year, a shared cache a day, because a repository can later turn private or be deleted.
  • Tests.

- The admin endpoint is tested for an anonymous caller (404), a non-admin token (404), and an admin (the full shape). - A bot UA carrying a token is tested for exemption: 18 requests, all 200. - The per-address, per-agent and shared budgets each have a test. - Two test files changed: crawl-budget.test.ts was rewritten for the new budgets, and the Disallow expectations changed with the robots.txt list.

  • Both doors. The new crawlReport in src/perf/crawlers.ts feeds both the API and a new terminal screen, "Crawlers" in the palette (src/tui/screens/crawlers.ts). The screen is for a forge admin who is a person, with its own test in test/tui-crawlers.test.ts. The API now also returns refused_by_budget (address, agent, shared), and the refused list is cut to 50 rows. README and docs/features/crawl-budget.md describe all of it.
  • One list of deep paths. DEEP_SEGMENTS now drives both the budget's deep-path regex and the Disallow: ///<segment> lines in robots.txt. That adds history and a slashless tree to robots.txt. The webspec KNOWN rewrite is unchanged.

What I chose not to do

  • A browser-looking User-Agent that rotates addresses is still not counted as a crawler. stranger_reads.ts bounds those.
  • The agent cap is still spoofable in aggregate: several addresses can together spend a name's 120 requests a minute. That is the cost of keeping a per-agent cap at all, and the address and shared budgets limit it.

To try it

  • curl -A 'Foobot/1' <preview>/<owner>/<repo>/blob/<full-sha>/<file> seven times from one address: the seventh answers 429.
  • /robots.txt lists the deep segments.
  • GET /api/v1/admin/crawlers with an admin token returns the share and the refusals.
commented

One of the two failures was caused by my change, and I've fixed it and pushed. I expect the check to pass now, but I haven't seen it run again.

  • "a site-admin person is offered the whole roster": this was mine. I added a "Crawlers" entry to the admin palette, and test/palette-roster.test.ts pins the full roster. I added the entry to the expected list, in the same position. That test and test/tui-workflows.test.ts both pass locally, and tsc is clean.
  • "require-review refuses the merge until the head is approved": I don't think my change causes this one. The test lives in test/tui-workflows.test.ts. It passes when I run the file alone, and neither it nor anything it opens touches the crawl budget, the palette or the cache headers I changed. It failed once among 68 shards, so I think it timed out under the check's load. If it fails again on this push, I'll look at it properly.
commented

I found no fault in this pull, so I changed no code and pushed an empty commit to make the forge run the check again.

  • The failure: shard 40 was killed after 37 s while running test/tui-width.test.ts. The log shows the shard's other 232 tests passing with 0 failures.
  • What I ran: test/tui-width.test.ts passes alone in 0.1 s. It also passes together with the three other tui- test files named in the log (tui-crawlers, tui-dashboard-governor, tui-check-memory): 16 pass in 1.8 s.
  • Why I think it is the machine: tui-width only tests pure width functions. It has no timers, servers or I/O, and nothing in this pull's diff adds a timer or worker that could hold a process open. I think the shard was killed under load rather than by a hang, but I couldn't confirm that from here.
  • Before pushing: bun scripts/gate.ts and bun x tsc --noEmit ran with no output.

If shard 40 is killed again, the cause is something I couldn't see here, and the next step is the full shard log with its tui-width output.

The pull itself:

  • Crawl budget is keyed on the client address, with a second per-agent cap and one shared ceiling.
  • Over-budget crawlers get a 429.
  • robots.txt lists the deep paths.
  • Commit-addressed pages are served as immutable.
  • There is a crawler-share report at GET /api/v1/admin/crawlers.
  • Forge admins who are people get a Crawlers screen in the terminal.

Try it with bun test test/crawl-budget.test.ts test/tui-crawlers.test.ts.

Approved

Earlier asks are met. The budget is keyed on the socket peer, with a per-agent cap and a shared ceiling. A missing User-Agent counts as a crawler. The agent table is bounded at 256 names. Anonymity comes from the resolved auth. Shared caches get s-maxage=86400. The admin door is tested for anonymous, non-admin and admin. The terminal has a Crawlers screen with its own test. README and the feature doc cover it. The robots.txt and deep-path lists share DEEP_SEGMENTS. Non-blocking: I did not run the preview or the tests myself; this approval rests on reading the diff.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs