Skip to content

Ask the callers with no name to do some arithmetic - #187

Merged
ralyodio merged 2 commits into
mainfrom
pow-challenge
Sep 7, 2026
Merged

Ask the callers with no name to do some arithmetic#187
ralyodio merged 2 commits into
mainfrom
pow-challenge

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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_ENABLED must be 1 and CHALLENGE_SECRET must 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: crawlThrottle meters a caller by address and UA token, tiers.js sorts them onto rungs, the gateway's chargeSpoofedBrowsers asks 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.

  • A per-caller limit sees 500 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 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.js makes 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's exempt() 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:

  • The API is never challenged. apiguard.js already 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.
  • Feed exports are never challenged (.rss, .atom, .json, .opml, .m3u, .pls). A challenge there breaks every subscriber silently and permanently.
  • Only /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 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. 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.js425 pass, 0 fail (22 new).
  • pnpm build — green.
  • The shipped hash text is run in the test and checked against Web Crypto at every padding edge (0, 55, 56, 63, 64, 65, 200 bytes). A hash wrong in one bit would produce a challenge nobody can ever solve, and the only symptom would be readers stuck forever while the logs said nothing.
  • End to end against a real server, with the page's own script lifted out of the response verbatim:
1. GET /topics/bing-ai            -> 503 (5,708 bytes, vs a 35,714-byte render)
2. solved 18 bits in 106ms
3. GET /topics/bing-ai + token    -> 200 (35,714 bytes)
4. same token, another address    -> 503   (not transferable)
5. /authors/... with same token   -> 200   (solve once, then browse)
  • And the exclusions, live: /api/topics/nfl-season, /topics/bing-ai.rss, / and /login all answered 200 with the feature on.

Turning it on

CHALLENGE_SECRET=<random>     # required; no default, because a shared constant is forgeable
CHALLENGE_ENABLED=1
CHALLENGE_BITS=18             # optional, 1-32
CHALLENGE_TTL_MINUTES=60      # optional, 1-1440

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

ralyodio and others added 2 commits September 7, 2026 02:17
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
@ralyodio
ralyodio merged commit 23f869e into main Sep 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant