Skip to content

Server: move the auth rate limiter to a shared sliding-window store (closes #79) - #116

Closed
testtest126 wants to merge 3 commits into
mainfrom
claude/smiley-face-1xzfkz
Closed

Server: move the auth rate limiter to a shared sliding-window store (closes #79)#116
testtest126 wants to merge 3 commits into
mainfrom
claude/smiley-face-1xzfkz

Conversation

@testtest126

@testtest126 testtest126 commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #79 (follow-up from the #77 security review).

The /auth/* throttle added for #32 counted requests in process memory: on a multi-instance deployment the effective limit becomes limit × instances, and restarts reset all windows. This PR moves the counters into the application database — the store all instances already share — and makes the window slide:

  • Shared store, no new dependency. A new auth_rate_windows table (key, bucket, count, unique on key + bucket) holds the counters. Each request is counted with one atomic upsert (INSERT … ON CONFLICT … DO UPDATE … RETURNING), so concurrent requests across instances can never both take the last free slot. The raw SQL is dialect-portable and runs on both Postgres (DATABASE_URL deployments) and SQLite (zero-spend volume, dev, tests) — same idiom as the existing AddUserAppleID migration. As a bonus, windows now survive restarts.
  • Sliding window. A verdict weighs the current bucket plus the previous bucket decayed linearly by bucket progress, closing the 2× burst that fixed windows admit across a boundary (called out in Rate limiter: shared store once the server scales past one instance #79). Refused requests are counted too, so hammering past the limit keeps the client limited; Retry-After still gives honest guidance for a client that goes quiet.
  • Bounded table. Buckets older than the previous window are swept opportunistically (at most once per window per instance), keeping the table at roughly two rows per recently active key — an attacker rotating source addresses can't grow it without bound, matching the old in-memory prune guarantee.
  • Same knobs (AUTH_RATE_LIMIT, AUTH_RATE_LIMIT_WINDOW), same middleware/keying behavior (Fly-Client-IP → last XFF → peer address), same 429 + Retry-After surface.

Trade-off worth noting: the limit check now costs a DB roundtrip before the endpoint handler runs. Every endpoint behind it already does DB work per request (register inserts, refresh queries), so the limiter still fences off the expensive paths; if that ever matters, an in-memory pre-filter can be layered in front without changing the store.

Rule 5 note: this touches abuse protection on the auth surface and #79 came out of a security review, so the PR stays draft pending the security verdict / orchestrator flow per CLAUDE.md.

Test plan

CI runs both suites; the sandbox for this session cannot run swift test locally (no toolchain, and the network policy blocks fetching one), so the boxes below reflect CI status — all green at head 01e4be4.

  • swift test in ChessKit/ passes (untouched by this PR)
  • swift test in chess-server/ passes (if server code changed)
  • App builds / relevant UI tests pass (if app code changed) — no app code changed; the iOS lane is green too

New/updated coverage in AuthAbuseTests:

  • limit admits then refuses within a window, and re-admits after the buckets age out (deterministic injected clocks on bucket-aligned instants)
  • cross-instance sharing: two limiter instances over one database refuse as one budget — the core of Rate limiter: shared store once the server scales past one instance #79
  • boundary smoothing: a full burst at the end of one window blocks a fresh burst just past the boundary, then admits after decay
  • stale-bucket sweep keeps only live rows
  • existing HTTP 429 / Retry-After / proxy-header keying tests, ported to the new limiter

🤖 Generated with Claude Code

https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T

claude added 3 commits July 11, 2026 14:04
…loses #79)

The /auth/* throttle added for #32 counted requests in process memory, so
a multi-instance deployment would multiply the effective limit by the
instance count and every restart reset all windows. Counters now live in
a new auth_rate_windows table in the application database — the store all
instances already share — via an atomic upsert
(INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING), which works on both
Postgres and SQLite.

The window also slides now: a verdict weighs the current bucket plus the
previous one decayed linearly, closing the 2x burst that fixed windows
admit across a window boundary. Refused requests count too, so hammering
past the limit keeps the client limited. Stale buckets are swept at most
once per window to keep the table at ~two rows per active key.

Same knobs as before (AUTH_RATE_LIMIT, AUTH_RATE_LIMIT_WINDOW); tests now
cover cross-instance sharing, boundary smoothing, and the sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
SwiftFormat's indent rule wants multiline string contents at the
statement's own indentation (the existing Migrations.swift style),
not one level deeper. String contents are unchanged — Swift strips
indentation relative to the closing delimiter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
From an adversarial review of the PR diff:
- The stale-bucket sweep now runs after the verdict is decided and logs
  failures instead of throwing — housekeeping could previously turn an
  already-decided request into a 500.
- The sweep reclaims any bucket outside current±1, so changing
  AUTH_RATE_LIMIT_WINDOW (which renumbers buckets) can't strand rows,
  and a clock-ahead peer's current bucket survives.
- Keys are clamped to 64 chars: real keys are IPs, and a hostile
  proxy-supplied header must not blow index row-size limits and turn
  the upsert into an error path.
- Refusal counting saturates at limit+1, restoring the fixed window's
  recovery-after-rollover for hammered keys and keeping Retry-After
  honest; tests pin the exact retry value and the saturation cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T

Copy link
Copy Markdown
Owner Author

Self-review note for the security reviewer: ran an adversarial multi-agent review over this diff (5 lenses, findings verified by independent judges). Fixed at 4b3cbea: sweep no longer runs before the verdict or fails the request (a housekeeping DELETE error could 500 an already-decided check); sweep reclaims buckets outside current±1 (an AUTH_RATE_LIMIT_WINDOW change renumbers buckets and previously stranded rows forever); keys clamped to 64 chars (a hostile proxy header could exceed Postgres index row limits and turn the upsert into a 500); refusal counting saturates at limit+1 (restores fixed-window recovery-after-rollover for hammered/shared keys, keeps Retry-After honest). New tests pin the exact Retry-After decay math and the saturation cap.

Reviewed and accepted as-is (for the verdict's awareness): every request costs a DB roundtrip before shedding — documented trade-off in the PR body, an in-memory pre-filter can layer on later without changing the store; fail-closed on DB errors (the endpoints behind the limiter can't work without the DB anyway); the previous-bucket SELECT can miss a peer's in-flight upsert at a boundary (sliding window is approximate by design); table growth between sweeps is bounded by distinct source addresses per two windows at ~one small row each.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@orchestrator — merge request from the smiley-face session (this session lacks ccd_session_mgmt, hence the comment instead of a DM). Three PRs are green and ready for the pipeline:

  1. Server: move the auth rate limiter to a shared sliding-window store (closes #79) #116 (this one, 4b3cbea) — needs the rule-5 security verdict first (auth-surface rate limiting; self-review findings + fixes documented above), then merge. Server-only.
  2. README screenshots via an on-demand CI capture harness (issue #118) #119 (e72aba5) — screenshot harness + README board shots (closes README: the only screenshot is the stock lobby — lead with the board #118). Touches workflows/docs/one self-skipping UI test; no production code.
  3. iOS: warm-classic home screen — brand accent, theme tiles, richer history (closes #117) #120 (3c10ce8) — warm-classic home screen (closes iOS: home screen design pass — give the lobby some chess identity #117). App-only; the path-filtered iOS lane is green on it, per rule 4(c).

No shared files between the three — any order works. All are drafts per house style; happy to mark ready on request or leave that to you.


Generated by Claude Code

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.

Rate limiter: shared store once the server scales past one instance

2 participants