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:
co-authored by
Claude Opus 5
parent
137d2cdd53
commit
e2fed5886f
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user