docs(anubis): record the challenge-flow contract and the JS-canary gotcha

The policy must ALLOW Anubis's own endpoints before the challenge-all rule
or the challenge challenges itself: browsers POST-solve via make-challenge
then GET-navigate pass-challenge, and a challenged answer renders as the
browser's own fetch-failure error page. Non-JS clients never reach the
verify step, which is why every curl probe looked green while every human
hit a wall. Also records that v1.13.0's valid action verbs are
ALLOW/CHALLENGE/DENY (ACCEPT crash-looped the unit on first deploy).
This commit is contained in:
Frank
2026-08-29 01:57:39 -07:00
parent e10ebf3df4
commit e006ad1f0f
+26 -6
View File
@@ -28,12 +28,19 @@ graph LR
| Policy | `dankclaw/anubis/policy.json` mounted read-only into the container; parsed at process start only (no reload signal exists — every policy change needs a unit restart) |
| Store | memory (no signing keys needed); a restart invalidates issued challenge JWTs — users simply re-challenge once |
Policy content: DENY for AI-scaper user-agent regexes (GPTBot, Claude-*,
Perplexity*, CCBot, Bytespider, Amazonbot, meta-externalagent, and friends),
CHALLENGE for everything else (`path_regex: "."` catch-all), DNSBL off.
Policy schema at this tag is JSON-only — no `(data)/` imports and no CEL;
first match wins. Upgrading the pin = stop the unit, pull + re-tag the
`localhost/` alias, restart; re-verify the policy schema against the new tag first.
Policy content: an `ALLOW` rule for Anubis's own endpoints (the
`/.within.website/x/cmd/anubis/*` API and static namespace — the challenge
JS calls `make-challenge`/`pass-challenge` under that prefix, and challenging
those endpoints makes the challenge challenge itself), then DENY for
AI-scraper user-agent regexes (GPTBot, Claude-*, Perplexity*, CCBot,
Bytespider, Amazonbot, meta-externalagent, and friends), then CHALLENGE for
everything else (`path_regex: "."` catch-all). DNSBL off. Valid action verbs
at this tag are `ALLOW`, `CHALLENGE`, `DENY` — `ACCEPT` is not one of them
(first deploy of that rule crash-looped the unit on exactly that typo;
verified against the image binary's string table). The policy schema at this
tag is JSON-only — no `(data)/` imports and no CEL; first match wins.
Upgrading the pin = stop the unit, pull + re-tag the `localhost/` alias,
restart; re-verify the policy schema against the new tag first.
## Exempt by construction
@@ -67,6 +74,19 @@ ungated), and gitea (git smart-HTTP clients cannot solve JS challenges —
5. **Deploy handler pull guard.** `deploy_anubis` guards the pull with
`podman image exists`; a cold anonymous ghcr token request can 403 on the
first fetch — retry once, the auth cache makes the second attempt local.
6. **JS-capable clients are the canary.** Non-JS clients (curl, bots,
scrapers) never solve challenges — they just receive the challenge page,
so every curl probe looks green while the verification step is broken.
The first JS-capable client to traverse a freshly deployed chain is the
first real test of it: browsers POST-solve via `make-challenge` and
GET-navigate `pass-challenge?response=<sha256(challenge+nonce)>&nonce=N
&elapsedTime=T&redir=<path>`; if any own-endpoint path is challenged
instead of allowed, the browser's fetch throws and it renders its own
error page (iOS/macOS browsers phrase it as "the request failed and the
interceptors did not return an alternative response") instead of the
login flow. Verify with a JS-capable client end-to-end — mint via
`make-challenge`, solve, submit with browser-exact query params — before
declaring a challenge chain healthy.
## Verify live