Fix: sign-in and claim endpoints are rate limited, with limits shared across instances (0.140.16) #103

Merged
pyaniz merged 1 commit from staging into main 2026-10-26 21:15:02 -05:00
Collaborator

What

Closes audit item A-09. Sign-in, linking and claim endpoints now have rate limits that every Vercel instance shares, and the client IP is no longer taken from a caller-written X-Forwarded-For.

  • Client IP (shared/client_ip.py): on Vercel, the last x-forwarded-for entry (the one Vercel writes). Elsewhere the socket address, or CLIENT_IP_HEADER / TRUSTED_PROXY_HOPS. Non-public addresses are not counted (one warning in the log), so a wrong setup cannot block everybody. IPv6 counts per /64.
  • Shared limits (shared/db_rate_limit.py, migration V64): fixed-window counters in an UNLOGGED Postgres table, keyed by an HMAC of the IP / handle / user id. If the check fails the request goes through.
  • Applied to: /auth/login and /register (per handle and per IP, since each login sends a Telegram message), /check, /verify, /email/verify, /discord/callback, /poll, Discord and Telegram link/merge, group and player claim, the Tabletop Time webhook, /games/local-search. Over the limit: 429 with Retry-After and "Try again in N minutes".
  • Frontend: three login catch blocks no longer show a rate limit as "invalid or expired link".
  • Full table of limits: docs/guides/Authentication.md.

Verified

  • Migration applied twice on Postgres 17 (idempotent, table is unlogged); counter SQL, window rollover, retry_after and pruning checked; 20 parallel clients x 50 calls ended at exactly 1000 hits.
  • 19 new tests (test_client_ip.py, test_rate_limit.py); reverting to "trust the leftmost x-forwarded-for" makes 5 of them fail.
  • Routers import and each limit is registered on the intended route. npm run build passes.

Not verified

  • The tests were run with a small stand-in runner (pytest is not installed on my machine): please run python -m pytest in backend.
  • The endpoints were not run end to end against a database.
  • What Vercel presents to the backend service after the /api rewrite.

To test on staging

  1. Open the app and check the logs: the AUTH ... IP: lines should show real visitor addresses, not one shared or private address (if they do, a warning "is not a public IP" is logged and per-IP limits stay off).
  2. Call /auth/login 6 times for one handle within an hour: the 6th should answer 429 "Try again in N minutes", and the login page should show that text.
  3. Sign in normally with Telegram, Discord and email; the login page polls every 2 s and should not be affected.
  4. Send a forged header (X-Forwarded-For: 1.2.3.4) while hammering /auth/check: it should still be limited after 20 calls in a minute.

Versioned 0.140.16 (fix); release notes added.

🤖 Generated with Claude Code

## What Closes audit item A-09. Sign-in, linking and claim endpoints now have rate limits that every Vercel instance shares, and the client IP is no longer taken from a caller-written `X-Forwarded-For`. - **Client IP** (`shared/client_ip.py`): on Vercel, the last `x-forwarded-for` entry (the one Vercel writes). Elsewhere the socket address, or `CLIENT_IP_HEADER` / `TRUSTED_PROXY_HOPS`. Non-public addresses are not counted (one warning in the log), so a wrong setup cannot block everybody. IPv6 counts per /64. - **Shared limits** (`shared/db_rate_limit.py`, migration **V64**): fixed-window counters in an UNLOGGED Postgres table, keyed by an HMAC of the IP / handle / user id. If the check fails the request goes through. - **Applied to**: `/auth/login` and `/register` (per handle and per IP, since each login sends a Telegram message), `/check`, `/verify`, `/email/verify`, `/discord/callback`, `/poll`, Discord and Telegram link/merge, group and player claim, the Tabletop Time webhook, `/games/local-search`. Over the limit: 429 with `Retry-After` and "Try again in N minutes". - **Frontend**: three login catch blocks no longer show a rate limit as "invalid or expired link". - Full table of limits: `docs/guides/Authentication.md`. ## Verified - Migration applied twice on Postgres 17 (idempotent, table is unlogged); counter SQL, window rollover, `retry_after` and pruning checked; 20 parallel clients x 50 calls ended at exactly 1000 hits. - 19 new tests (`test_client_ip.py`, `test_rate_limit.py`); reverting to "trust the leftmost x-forwarded-for" makes 5 of them fail. - Routers import and each limit is registered on the intended route. `npm run build` passes. ## Not verified - The tests were run with a small stand-in runner (pytest is not installed on my machine): please run `python -m pytest` in `backend`. - The endpoints were not run end to end against a database. - What Vercel presents to the backend service after the `/api` rewrite. ## To test on staging 1. Open the app and check the logs: the `AUTH ... IP:` lines should show real visitor addresses, not one shared or private address (if they do, a warning "is not a public IP" is logged and per-IP limits stay off). 2. Call `/auth/login` 6 times for one handle within an hour: the 6th should answer 429 "Try again in N minutes", and the login page should show that text. 3. Sign in normally with Telegram, Discord and email; the login page polls every 2 s and should not be affected. 4. Send a forged header (`X-Forwarded-For: 1.2.3.4`) while hammering `/auth/check`: it should still be limited after 20 calls in a minute. Versioned 0.140.16 (fix); release notes added. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fix: sign-in and claim endpoints are rate limited, with limits shared across instances (0.140.16)
All checks were successful
Build and Publish Containers / build-and-push (push) Successful in 52s
Mirror to GitHub / mirror (push) Successful in 21s
1c57eff8bf
Only email login links had a rate limit. Telegram sign-in could be called without
limit, which sent the owner of a handle a login message every time, and the other
sign-in, linking and claim endpoints were open too. The in-memory limiter believed
a caller-written X-Forwarded-For and counted per Vercel instance.

- shared/client_ip.py: the caller's IP from a header the platform sets (Vercel's
  last x-forwarded-for entry, or CLIENT_IP_HEADER / TRUSTED_PROXY_HOPS), else the
  socket address. Non-public addresses are not counted. IPv6 counts per /64.
- shared/db_rate_limit.py + migration V64: fixed-window counters in an UNLOGGED
  Postgres table, keyed by an HMAC of the IP / handle / user id. Fails open.
- Limits on login and register (per handle and IP), check, verify, email verify,
  Discord callback and link, Telegram link and merge, polling, group and player
  claim, the Tabletop Time webhook and local game search. 429 carries Retry-After.
- Login page shows the "try again in N minutes" text for the three flows that
  used to report any error as an invalid link.
- Tests: test_client_ip.py, test_rate_limit.py. Docs: Authentication.md,
  .env.example, audit item A-09.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
pyaniz merged commit e9c2f9b9fe into main 2026-10-26 21:15:02 -05:00
Sign in to join this conversation.
No description provided.