feat(link): rate-limit /v1/link and /v1/repos

Last unbuilt item on the promotion-window hardening checklist. /v1/link
round-trips Zitadel, can CREATE a forge account and always mints a token, so
it is the endpoint that must not be free to hammer.

Sliding window per (route, client), ON by default -- unlimited has to be a
deliberate config act, not an omission. Defaults 5/hour and 60/hour; /health
never limited. 429 + Retry-After, decided BEFORE the body is read so an
abusive caller costs nothing.

Decisions worth naming:
- State is an in-process dict behind a lock. granthi-link is ONE
  ThreadingHTTPServer, so that IS the store -- no redis. Kept behind a class
  so a future multi-process move has one thing to change.
- Denied requests are NOT recorded. Recording them lets a hammering client
  push its own window forward and lock itself out forever.
- Key store is capped; at capacity it drops least-recent windows and logs
  loudly. Fail-open under key pressure, chosen over an unbounded dict that
  is a memory DoS.
- trust_forwarded_for OFF by default. Behind cloudflared every request comes
  from the tunnel, so limiting on the socket peer starves everyone; but XFF
  is client-controlled. A caller can PREPEND, a trusted proxy APPENDS what it
  actually saw -- so we read the LAST entry, never the first.
- A malformed rule refuses startup instead of silently meaning unlimited.

Tests 69 -> 87, including a 40-thread race proving the lock holds, the
self-lockout case, XFF spoof-resistance, and a real 429 on the wire.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
This commit is contained in:
Nirav Patel
2026-08-22 23:14:54 -04:00
co-authored by Claude Opus 5
parent 137d2cdd53
commit e2fed5886f
4 changed files with 351 additions and 3 deletions
+37 -2
View File
@@ -90,6 +90,38 @@ window. The service **refuses to start** (exit 2) unless `config.json` is
mode 0600/0400 and owned by the user it runs as — the config carries the
forge admin password, so permissive perms fail closed, not open.
#### Rate limiting
Sliding-window, per `(route, client)`, **on by default**`/v1/link` creates
accounts and mints tokens, so unlimited has to be a deliberate config act,
never an omission. Defaults: `/v1/link` 5/hour, `/v1/repos` 60/hour. `GET
/health` is never limited. Over the limit → **429** with a `Retry-After`
header, decided *before* the body is read so an abusive caller costs nothing.
```json
"rate_limit": {
"enabled": true,
"trust_forwarded_for": false,
"rules": {"/v1/link": [5, 3600], "/v1/repos": [60, 3600]}
}
```
* A malformed rule **refuses startup** rather than silently meaning
unlimited; `[0, N]` disables an endpoint outright.
* Denied requests are *not* recorded, so a client that keeps hammering cannot
push its own window forward and lock itself out permanently.
* State is an in-process dict behind a lock — granthi-link is one
`ThreadingHTTPServer`, so that is the entire store. **If this ever runs
multi-process or multi-host, the limiter must move with it.** The key store
is capped (`MAX_RATE_KEYS`); at capacity it drops least-recent windows and
logs loudly, which is fail-open, chosen over an unbounded dict that is a
memory DoS.
* `trust_forwarded_for` is **off** by default. Turn it on only behind
cloudflared, where every request otherwise arrives from the tunnel and one
abuser would starve everyone. A caller can *prepend* anything to
`X-Forwarded-For`; a trusted proxy *appends* the peer it actually saw, so
the service reads the **last** entry, never the first.
#### Identity binding (`state.json`)
`/v1/link` originally bound purely by `preferred_username` / email
@@ -226,8 +258,11 @@ Every linked folder becomes a private repo under their account.
app is host-independent. Rotate the beta admin token/password out of the
config when pointing at prod (prod forge is READ-ONLY to this estate —
promotion is an operator action, not an agent action).
4. Add rate limiting / abuse controls before public exposure (one token mint
per link call today).
4. ~~Add rate limiting / abuse controls before public exposure.~~ **DONE**
see "Rate limiting" above (5 links/hour per client by default). When
exposing behind cloudflared, set `trust_forwarded_for: true` in the same
change, or every request will look like the tunnel and one abuser will
throttle everybody.
5. **Hardening checklist (must all hold before exposing):**
- [ ] `config.json` is 0600 (or 0400) and owned by the service user —
the service refuses to start otherwise; verify with