Ask the callers with no name to do some arithmetic - #187
Merged
Conversation
Every limit this site had is keyed on who is asking. crawlThrottle meters a caller by address and user-agent token, tiers.js sorts them onto rungs, and the gateway's spoof check asks whether a thing claiming Chrome sends the headers Chrome cannot suppress. All three are beaten by the same move, and it is the move the traffic makes: ask from somewhere else, every time. Measured during the 2026-09-07 outage: 500 requests in 3.5 seconds from 500 distinct addresses, no address twice, two Chrome strings between them, and all 500 paths different across 329 topic slugs. A per-caller limit sees five hundred callers with one request each and refuses nobody; a cache is never asked the same question twice and hits nothing; the fleet already sends Sec-Fetch-Mode, so the spoof check waves it through. So this rung is keyed on what the caller can do. Before an expensive page is rendered, an anonymous caller is asked to find a number whose SHA-256 opens with 18 zero bits. Verifying is one hash and costs us nothing. The answer is bound to the address that solved it, which is the part the rotation cannot help with: five hundred addresses now means five hundred solves. Honest about what it is worth: not a wall. A real JavaScript engine can pay this, so against headless browsers it is a tax rather than a refusal. What it stops outright is the larger population that replays headers with no engine behind them. workers.js makes the same point about capacity; the two together are a slope, not a gate. Who is never asked, because each of these would be a bug rather than a policy: - crawl-gateway's exempt() is reused rather than copied, so a reader with a session, a program with an API key, and a named retrieval or search crawler pass exactly as they do at the 402. - The API is never challenged. apiguard.js argues that case already and it is right. A scraper pushed onto /api/topics/* reads 5 KB of JSON instead of rendering 47 KB of HTML, which is the trade we want. - Feed exports are never challenged. A challenge on a .rss breaks every subscriber silently and permanently. - Only /topics/*, /authors/* and */read, which was 400 of the 500 requests. Off unless switched on. CHALLENGE_ENABLED defaults to off and without CHALLENGE_SECRET it stays off however it is set, because a forgeable token costs every reader a second and costs the fleet nothing. This is the one limit here that a reader can see, so it arrives when someone decides the traffic is worth interrupting people for, not with a deploy. Two things the measurements changed: The hash is written out rather than called through crypto.subtle. A promise per hash is the dominant cost — Web Crypto managed 67k hashes/s end to end, which would have made 18 bits an eight-second wait. The first hand-written version was worse still at 17k/s, because applying SHA-256's padding through a function call per byte costs a thousand calls a block; the solver now owns the padding and rebuilds it on the ten occasions the nonce gains a digit. That reads 610k hashes/s, so 18 bits is about 0.43s, and it solved in 106ms against the real server. The benchmark itself had to leave the vm sandbox. Typed arrays across a vm boundary defeat the JIT: the same text does 610k hashes/s under new Function and 10k inside vm.runInNewContext, so a sandboxed benchmark would have condemned a solver that is fast in the browser it is written for. Verified end to end against a real server: 503 with a 5.7 KB interstitial in place of a 35.7 KB render, solved in 106ms, the token admits, the same token from another address is refused, and one solve carries across pages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPvk8mVEpxWwTRFby9m8VT
CI failed at 277,778 hashes/s against a threshold of 300,000 — a threshold set from the development box's own 610,000, which is the mistake. The number that matters is what a reader waits: 262,144 hashes at 18 bits is 0.43s here and 0.95s on the runner, and both are fine. So the assertion is now the projected wait with a bar far below either. It still catches the two regressions that each happened once — Web Crypto at 67k/s, an eight-second wait, and the first hand-written hash at 17k/s — and it no longer fails on hardware that is merely slower than mine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPvk8mVEpxWwTRFby9m8VT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The next rung after #186. That one made the site survive the fleet by using the cores it was already paying for; this one makes the fleet pay something.
Off by default.
CHALLENGE_ENABLEDmust be1andCHALLENGE_SECRETmust be set. Merging changes nothing until both are.Why the rungs below this one do not reach
Every limit the site had is keyed on who is asking:
crawlThrottlemeters a caller by address and UA token,tiers.jssorts them onto rungs, the gateway'schargeSpoofedBrowsersasks whether a thing claiming Chrome sends the headers Chrome cannot suppress. All three lose to the same move, and it is the move the traffic makes — ask from somewhere else, every time.From the 2026-09-07 outage sample: 500 requests in 3.5 s, 500 distinct addresses, no address twice, two Chrome strings between them, and all 500 paths different across 329 topic slugs.
Sec-Fetch-Mode, so the spoof check waves it through.So this rung is keyed on what the caller can do. Before an expensive page renders, an anonymous caller must find a number whose SHA-256 opens with 18 zero bits. Verifying is one hash. The answer is bound to the address that solved it, which is what the rotation cannot route around: five hundred addresses now means five hundred solves.
What it is honestly worth
Not a wall. A real JavaScript engine can pay this, so against headless browsers it is a tax rather than a refusal — one solve per address per hour instead of per request. What it stops outright is the much larger population replaying headers with no engine behind them.
workers.jsmakes the same point about capacity. The two together are a slope, not a gate, and the module says so where someone will read it.Who is never asked
crawl-gateway.js'sexempt()is reused, not copied, so the two can never drift about who is welcome: a reader with a session, a program with an API key, a named retrieval or search crawler. Training crawlers never reach this code — the gate answers them 402 first. Three more exclusions, each of which would be a bug rather than a policy:apiguard.jsalready argues that case and is right. A scraper pushed onto/api/topics/*reads 5 KB of JSON instead of rendering 47 KB of HTML — the trade we want..rss,.atom,.json,.opml,.m3u,.pls). A challenge there breaks every subscriber silently and permanently./topics/*,/authors/*and*/read— 400 of the 500 sampled requests. Everything else is too cheap to interrupt anyone for.Two things the measurements changed
The hash is written out rather than called through
crypto.subtle. A promise per hash dominates: Web Crypto managed ~67k hashes/s end to end, making 18 bits an eight-second wait. The first hand-written version was worse at 17k/s, because applying SHA-256's padding through a function call per byte costs ~1,000 calls per block. The solver now owns the padding and rebuilds it on the ten occasions the nonce gains a digit → 610k hashes/s, so 18 bits is ~0.43 s.The benchmark had to leave the
vmsandbox. Typed arrays across a vm boundary defeat the JIT — the same text does 610k hashes/s undernew Functionand 10k insidevm.runInNewContext. A sandboxed benchmark would have condemned a solver that is fast in the browser it is written for. The test now compiles it the way a browser does, and says why.Verification
node --test test/*.test.js— 425 pass, 0 fail (22 new).pnpm build— green./api/topics/nfl-season,/topics/bing-ai.rss,/and/loginall answered 200 with the feature on.Turning it on
Junk in any of them falls back to the default rather than to zero — a typo must not quietly remove the difficulty while leaving the interruption in place. To turn it off, unset
CHALLENGE_ENABLED.🤖 Generated with Claude Code
https://claude.ai/code/session_01KPvk8mVEpxWwTRFby9m8VT