fix(auth): three ways one guest on the venue NAT could lock everyone else out

All three are the same mistake in different clothes: a limit keyed on an IP
that, behind the venue's NAT, is the entire party plus the host.

* join_ip_rate_per_min was raised 60 -> 300 last round and it never took
  effect. A config default is only a fallback for a MISSING key, and
  migration 017 seeds this one, so the seed won and the raise was dead code
  on every real install. Migration 030 raises the seeded value the way 015
  already did for upload_rate_per_hour. The e2e guard could not see this:
  it fires 12 concurrent joins, which is green at 60 and at 300 alike.

* /recover's per-(IP, name) bucket charged EVERY request, including
  successful ones, and refused before verifying the PIN. Its ceiling clamps
  to 4. So four POSTs naming "Braut Sophie" with PIN 0000, from any phone on
  the venue wifi, locked Sophie out of her own recovery for fifteen minutes
  WITH THE CORRECT PIN — and four more every fifteen minutes sustained it
  indefinitely, at a rate far under every volume ceiling above it. The benign
  version needs no attacker: the host mistypes their own PIN four times.
  Hosts are promoted guests whose only credential is that PIN, and /recover
  is their only way back after losing a session.

  Now it counts failures, and a spent budget changes what a FAILURE answers
  instead of refusing outright. Guessing is bounded exactly as before —
  wrong PINs are what spend it — with the per-account lockout underneath.

* /admin/login's pre-verify ceiling had the same shape, and the escape hatch
  was circular: admin_login_rate_enabled is only flippable through
  PATCH /admin/config, which needs the session being refused. One phone
  posting twice a minute cost the operator moderation, gallery release and
  every config key, including the ones that would undo it. Exceeding the
  ceiling now shortens the hash-permit wait rather than refusing: the CPU
  bound was always the semaphore, never this bucket, so a flood still sheds
  itself while a correct password gets a truthful answer.

Adds a regression test that reads the value a fresh database actually ends
up with, by replaying the migrations — the drift that made the first bullet
invisible is not otherwise detectable from the code.
This commit is contained in:
fabi
2026-08-12 09:14:39 +02:00
parent 0c0d5d5981
commit 1b3ca46f8a
6 changed files with 337 additions and 88 deletions

View File

@@ -0,0 +1,15 @@
-- Raise the per-IP join ceiling from 60/min to 300/min.
--
-- Rationale: every guest at the venue arrives through one NAT'd public address,
-- so `join_ip:{ip}` is not a per-guest limit at all — it is a ceiling on the
-- whole party. The QR code goes up once and is scanned in a burst: at 60/min,
-- guest 61 onwards is refused on the join screen, which is the one screen with
-- no auto-retry, and every manual retry spends another slot.
--
-- The code default was already raised to 300 (auth/handlers.rs), but a default
-- only applies when the key is ABSENT, and migration 017 seeds it. Without this
-- UPDATE the raise is dead code on every existing install.
--
-- Only bump installs still on the seeded default; an admin who deliberately set
-- a different value keeps it (migration 017 seeded 60; this UPDATE is scoped to '60').
UPDATE config SET value = '300' WHERE key = 'join_ip_rate_per_min' AND value = '60';