fix(api): bound what a forged CF-Connecting-IP can buy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
Code review, both findings valid. The design doc claimed Cloudflare "replaces the header, so a browser cannot forge it", and that only the X-Forwarded-For fallback was forgeable. That is true only for traffic that actually passed through Cloudflare, and nothing in this process can verify that it did. Reaching the origin directly, both headers are equally attacker-controlled — and rotating CF-Connecting-IP mints a fresh rate-limit bucket per request, defeating per-client limits on every endpoint including the DataFrame-heavy /api/schools. Against abuse that is worse than the shared bucket it replaced, which at least capped everyone together. So the ceiling comes back. I dropped it earlier arguing it belonged at Cloudflare; that argument assumed the keying was sound, and it is not. GlobalRateLimitMiddleware counts all /api/ traffic in a fixed window against a total, independent of client identity, outermost so it refuses before any work happens. Written by hand because slowapi cannot express a global cap: default_limits and application_limits are both keyed by key_func, and the latter needs middleware this app does not install. It does not make the header trustworthy — it makes trusting it survivable. The real fix is Authenticated Origin Pulls or an origin firewall, now documented in DEPLOY.md as the open gap it is. 127.0.0.1 is exempt: the healthcheck curls localhost from inside the container, and starving it would restart the container and turn a load spike into an outage loop. Keyed on the peer address, never the Host header, which the caller sets. Second finding: suggest_schools_typesense promised "never raises" while the parsing loop sat outside the try, so int(None) on a malformed document would have made a keystroke a 500. The loop now skips bad rows rather than dropping the whole list — and a hit with no document no longer becomes a suggestion pointing at /school/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
This commit is contained in:
1 parent
d2115364ae
commit
0fa1a292c7
8 files changed
+332
-50
No files matched your search
@@ -1335,14 +1335,20 @@ site-wide; autosuggest itself is off until toggled in Unleash; and that
|
||||
**Task 1 changes rate limiting for every endpoint.** It is the one change here
|
||||
that is not behind a flag, and it is the one worth the most review attention.
|
||||
Before it, everyone shares one 60/minute bucket; after it, each caller gets
|
||||
their own. That is the intended fix, and it also removes an accidental global
|
||||
throttle — the spec's §1 and Risks say so plainly. A global ceiling belongs at
|
||||
Cloudflare and is deliberately not built here.
|
||||
their own, bounded by a global ceiling. The header it keys on is only
|
||||
trustworthy for traffic that actually passed through Cloudflare, which this
|
||||
app cannot verify — closing that needs Authenticated Origin Pulls or an origin
|
||||
firewall, and is the most valuable follow-up in the spec's Risks.
|
||||
|
||||
**Do not add an in-app global rate limit.** An earlier spec draft did. slowapi's
|
||||
`default_limits` and `application_limits` are both keyed by `key_func`, so they
|
||||
are per-client rather than global, and `application_limits` only apply with
|
||||
`SlowAPIMiddleware` installed, which this app does not use.
|
||||
**The in-app global ceiling is required, and slowapi cannot express it.**
|
||||
Code review found that `client_key` trusts `CF-Connecting-IP` with no way to
|
||||
verify the request reached the origin through Cloudflare — so rotating that
|
||||
header mints a fresh bucket per request and defeats per-client limits entirely,
|
||||
which is worse against abuse than the shared bucket it replaced. The ceiling
|
||||
(`GlobalRateLimitMiddleware`) bounds that, and is written by hand because
|
||||
slowapi's `default_limits` and `application_limits` are both keyed by
|
||||
`key_func` — per-client, not global — and the latter needs `SlowAPIMiddleware`,
|
||||
which this app does not install. See spec §1.1.
|
||||
|
||||
**`onMouseDown`, not `onClick`, on the options.** The input's `onBlur` closes
|
||||
the list and blur fires first, so a click handler never runs. This is the
|
||||
|
||||
Reference in new issue
Block a user