caddy: the signup rate-limit zone counts 4 requests per sign-up against a 10/min budget #304

Stängd
öppnade 2026-08-01 14:09:52 +00:00 av supernaut · 0 kommentarer
Ägare

The signup rate-limit zone budgets one request per sign-up. A real sign-up costs four, so the
effective limit is two sign-ups per minute per client IP — and on a shared IPv4 egress that budget is
shared by everyone behind it.

The arithmetic

roles/caddy/templates/Caddyfile.j2 matches path /signup /api/signup /api/captcha/*, keyed
{remote_host} with ipv6_prefix 56, at caddy_ratelimit_signup_events: 10 / window: 1m.

One completed sign-up hits four of those paths:

# Request Why it is matched
1 GET /signup the page itself is in the match set
2 POST /api/captcha/challenge Cap fetches a challenge on interaction
3 POST /api/captcha/redeem Cap is a two-call widget — see the header comment in bitborg-web/src/lib/captcha.ts
4 POST /api/signup the submission, which spends the cap-token

So events 10 permits two sign-ups per minute per IP, not ten. A visitor who mistypes and
resubmits re-solves the captcha and consumes roughly seven of the ten unaided. Visitors who merely
load /signup and leave spend the same budget as people who convert.

Why it matters now

Announcement traffic is bursty and concentrated behind shared egresses — corporate and university NAT,
mobile CGNAT — where every visitor presents the same IPv4 to Caddy. ipv6_prefix 56 is a reasonable
per-household grouping, so IPv6 is not the exposure; shared IPv4 is.

The failure mode is the bad one: a 429 arriving mid-captcha reads as a broken site rather than as a
rate limit, and it lands precisely at the point of conversion.

This is the third instance of the same shape in this area — the reconciler was throttled because it
reaches Forgejo over the public URL and all its calls share one {remote_host} key, and #195 was a
fail2ban ban on the runners' shared SNAT egress. A limit keyed on a shared egress identity punishes
the group for the behaviour of one member.

Suggested direction

Count state changes, not page views: scope the zone to POST /api/signup and
/api/captcha/redeem, drop GET /signup from the match set, then size the budget from what a real
flow costs plus retries. That keeps the protection where abuse actually happens — challenge redemption
and account creation — instead of spending it on people reading the page. Simply raising
caddy_ratelimit_signup_events is a defensible launch-day measure but leaves the mis-sizing in place.

Check the in-process limiter in bitborg-web/src/lib/rate-limit.ts at the same time. The edge zone is
documented as defence-in-depth over it, so if the app-level limiter is keyed on client IP with a
similarly tight budget, changing only Caddy fixes nothing.

Found while verifying launch readiness; being addressed together with the #302 tuning pass since it is
the same file and the same decision.

The `signup` rate-limit zone budgets **one** request per sign-up. A real sign-up costs **four**, so the effective limit is two sign-ups per minute per client IP — and on a shared IPv4 egress that budget is shared by everyone behind it. ## The arithmetic `roles/caddy/templates/Caddyfile.j2` matches `path /signup /api/signup /api/captcha/*`, keyed `{remote_host}` with `ipv6_prefix 56`, at `caddy_ratelimit_signup_events: 10` / `window: 1m`. One completed sign-up hits four of those paths: | # | Request | Why it is matched | | - | ------- | ----------------- | | 1 | `GET /signup` | the page itself is in the match set | | 2 | `POST /api/captcha/challenge` | Cap fetches a challenge on interaction | | 3 | `POST /api/captcha/redeem` | Cap is a **two-call** widget — see the header comment in `bitborg-web/src/lib/captcha.ts` | | 4 | `POST /api/signup` | the submission, which spends the `cap-token` | So `events 10` permits **two** sign-ups per minute per IP, not ten. A visitor who mistypes and resubmits re-solves the captcha and consumes roughly seven of the ten unaided. Visitors who merely load `/signup` and leave spend the same budget as people who convert. ## Why it matters now Announcement traffic is bursty and concentrated behind shared egresses — corporate and university NAT, mobile CGNAT — where every visitor presents the same IPv4 to Caddy. `ipv6_prefix 56` is a reasonable per-household grouping, so IPv6 is not the exposure; shared IPv4 is. The failure mode is the bad one: a 429 arriving mid-captcha reads as a broken site rather than as a rate limit, and it lands precisely at the point of conversion. This is the third instance of the same shape in this area — the reconciler was throttled because it reaches Forgejo over the public URL and all its calls share one `{remote_host}` key, and #195 was a fail2ban ban on the runners' shared SNAT egress. A limit keyed on a shared egress identity punishes the group for the behaviour of one member. ## Suggested direction Count state changes, not page views: scope the zone to `POST /api/signup` and `/api/captcha/redeem`, drop `GET /signup` from the match set, then size the budget from what a real flow costs plus retries. That keeps the protection where abuse actually happens — challenge redemption and account creation — instead of spending it on people reading the page. Simply raising `caddy_ratelimit_signup_events` is a defensible launch-day measure but leaves the mis-sizing in place. Check the in-process limiter in `bitborg-web/src/lib/rate-limit.ts` at the same time. The edge zone is documented as defence-in-depth over it, so if the app-level limiter is keyed on client IP with a similarly tight budget, changing only Caddy fixes nothing. Found while verifying launch readiness; being addressed together with the #302 tuning pass since it is the same file and the same decision.
Logga in för att delta i denna konversation.
Ingen milstolpe
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
bitborg/bitborg-infra#304
Ingen beskrivning angiven.