Make the rate limits actually run - #725
Merged
Merged
Conversation
|
ArchLens - No architecturally relevant changes to the existing views |
mircealungu
marked this pull request as draft
September 8, 2026 09:38
Every entry in RATE_LIMITS was keyed "api.<view>", but the blueprint is
declared as Blueprint("endpoints", ...) and only assigned to a variable
named `api`. So every lookup returned None, limiter.limit() was handed
None, and a bare `except Exception: pass` swallowed the failure at
startup. Login has been accepting unlimited attempts for as long as the
file has existed, while reading as though it were capped at 5 a minute.
Correcting the prefix was not enough on its own: limiter.limit(...) was
being called for its side effect and its return value dropped, which
registers the limit under a name that request dispatch never consults.
The decorated function now goes back into app.view_functions.
An endpoint name that doesn't resolve is now a startup error rather than
a silent skip — the entire value of this file is that someone reading it
can believe it.
Two related fixes fall out of the same read:
- init_limiter ran before load_configuration_or_abort, so
RATELIMIT_STORAGE_URI was never visible and each worker counted in its
own memory regardless of configuration. It now runs after.
- The limiter is disabled under testing=True, since the suite logs in
far more than five times a minute as the same user.
Also adds a per-session limit to user search, which now accepts a full
email address as a search key: one query can only confirm an address the
searcher already typed, but an unbounded endpoint would let a script
sweep a wordlist to learn which addresses have accounts. Keyed by
session rather than IP so that a class behind one school NAT all adding
each other doesn't share a single quota.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The numbers in this file were written against an implementation that never ran, so they had never met real traffic. Login was 20 per hour per IP; a class of 60 behind one school NAT is a single bucket, so most of the room would have been turned away with a 429 they could not clear. Schools behind NAT are the core user base, so enabling the limits as written would have hit them first. Login and password reset now charge the bucket only for requests the endpoint rejected. Correct sign-ins cost nothing, so a full room signing in normally never touches the limit no matter how large the room, and the ceiling can stay low enough to still mean something: 1000 failures per hour from one address. That only holds for what an IP bucket can honestly cap, which is one host sitting there guessing. Anyone willing to rotate addresses walks around it, and the defence that stops them is a per-account limit, not yet here. Two tests now cover the direction this file had never been tested in: sixty legitimate logins from one address, and 150 correct sign-ins, must neither of them produce a 429. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
send_code deliberately answers "OK" even for addresses that don't exist, so that it can't be used to enumerate users. Charging it on failures only - as the previous commit did, lumping it in with login - therefore charged it nothing at all, leaving the limit decorative and the endpoint loopable: unlimited password reset mail aimed at whoever owns the address, on our SMTP bill. Login and reset-code submission are guessing surfaces, where the failure is the thing worth counting. Requesting a code is the opposite: the successful send is what costs. Every request counts there now. Forgetting a password is rare enough that a school stays far under 200 an hour, so this doesn't reintroduce the classroom problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A per-IP limit on send_code caps one host looping the endpoint, which is not the threat: an attacker willing to rotate addresses walks around any number we pick and the victim's inbox fills anyway. The address being mailed is named in the route, so key on that. Five an hour to any one inbox, regardless of where the requests come from - nobody legitimate needs a sixth, and a whole school forgetting their passwords is still one request each, so there is no crowd to catch. The per-IP limit stays on underneath as a backstop against spraying one message at each of many addresses. Endpoints can now carry several limits with different keys, which is what "tight limit on the thing being protected, loose backstop on the caller" requires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Raising add_anon_user along with the other two was scope creep with no evidence behind it. The classroom problem was about login, and the comment justifying the looser number - that invite codes are the real protection - is not true of this endpoint: it hands its invite_code to User.create_anonymous, which takes it as an optional argument and never validates it. Anonymous accounts also need no email address, so this is both the cheapest account to mass-create and the only creation path where the rate limit is the entire defence. Back to 200 an hour. Also rejects an unrecognised `count` at import. A misspelt one would quietly fall through to charging every request - the same species of silent misconfiguration this file exists to stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mircealungu
force-pushed
the
fix-inert-rate-limits
branch
from
September 17, 2026 22:12
030ce1e to
d7b53c0
Compare
Every request arrives through nginx, so request.remote_addr is the proxy: production access logs contain exactly two source addresses, 172.18.0.1 and 127.0.0.1, across two days of traffic. Every per-IP limit in this branch would therefore have been one global bucket shared by all users. That is worse than not limiting at all. It cannot pick an attacker out of the crowd, and a single host making 100 failed logins a minute would spend the budget for everybody -- turning the protection into a remote off-switch for logging in to Zeeguu. ProxyFix(x_for=1) reads the address nginx records in X-Forwarded-For, which the ops repo now sets with $remote_addr (overwriting rather than appending, so a caller cannot spoof it). Both halves are needed: without the nginx side every address here is None and the limits stay global. Tests cover both directions -- one host exhausting the budget must not lock out another, and that host must still be capped itself. Both fail with ProxyFix removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirty students share one school NAT, so every per-IP limit counts the whole room as one caller. And a spent bucket turns away every request on that key, not only the ones that spent it -- so an IP limit a class can reach does not inconvenience the strugglers, it locks the room out, including the students typing the right password. send_code was the one that would have gone first: at 20 a minute, a teacher saying "everyone who forgot their password, click now" would have seen ten of thirty students refused. So the real defence moves to where it belongs. get_session, get_anon_session and reset_password now count failures against the account named in the route, which is what an attacker rotating addresses cannot escape and what an IP bucket could never do -- #725's own comment said as much. A class is thirty separate buckets there, so the IP ceilings can rise to where no real classroom reaches them and serve as what they honestly are: a backstop against one host spraying many accounts. get_anon_session names its account by uuid rather than email, so the key reads either, and falls back to the IP if neither is present -- a constant would put every caller in one bucket, which is the failure this file exists to prevent. Tests now cover a class of thirty resetting passwords, typing codes in wrongly, mistyping passwords and signing up together, none of which may be throttled; and the converse, that one account is still capped however many addresses are used against it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/get_anon_session/<uuid> reads oddly next to /session/<email>, since there is no uuid column on user. The uuid is the local part of the address an anonymous account was generated with -- authorize_anonymous appends @anon.zeeguu and hands it to the ordinary authorize -- so it names an account exactly as much as the email does. Which means the two routes reach one account, and flask-limiter buckets per endpoint, so that account has ten guesses at each door rather than ten in total. Left as is: twenty wrong passwords a quarter of an hour is no more use to somebody guessing than ten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
None of the rate limits have ever been enforced
RATE_LIMITSkeys every entry asapi.<view>. The blueprint is declared asflask.Blueprint("endpoints", __name__)and merely assigned to a variable namedapi, so the real endpoint names areendpoints.*. Every lookup returnedNone:limiter.limit(...)was then handedNone, threw, and a bareexcept Exception: passswallowed it at startup. Nothing logged. Onmaster, 15 rapid failed logins against an endpoint documented as 5 per minute:Login brute-force protection, the password-reset throttle and the mass-registration cap are all inert in production, and have been since the file was added.
Correcting the prefix was not sufficient
With the names fixed, the limits registered but still didn't fire.
limiter.limit(...)was being called for its side effect with the return value dropped, which registers the limit under a name (…sessions.get_session.get_session) that request dispatch never consults — dispatch resolvesendpoints.get_sessionto the undecorated function still sitting inapp.view_functions. The decorated function now goes back into the routing table, which is what finally produces a 429.Worth noting because it's the second failure mode in a row where the call looked correct and did nothing.
Failing loudly
An endpoint name that doesn't resolve is now a
RuntimeErrorat startup instead of a silent skip:A typo taking the API down at boot is a much better failure than a typo quietly removing the login throttle. The whole value of this file is that a reader can believe it.
Two related fixes from the same read
init_limiterran beforeload_configuration_or_abort, soapp.config.get("RATELIMIT_STORAGE_URI")always fell through tomemory://. Configuring Redis could never have worked, and each gunicorn worker counted separately — meaning the real login limit, once enforced, is 5/minute per worker. It now runs after config load.testing=True. The suite logs in far more than five times a minute as the same user from the same address, so enforcement has to be opt-in per test.User search gets a limit
search_usersaccepts a full email address as a search key (#724). A single query only confirms an address the searcher already typed, but an unbounded endpoint would let a script sweep a wordlist to learn which addresses have accounts.Keyed per session rather than per IP: these endpoints sit behind
@requires_session, and a class behind one school NAT all adding each other at once is a lesson, not an attack. The key reads the session uuid the same wayrequires_sessiondoes but doesn't validate it — an invalid uuid gets rejected by the view anyway, and any opaque per-account token is fine for bucketing.Testing
Four new tests in
test_rate_limiting.py, each pinning a failure mode that actually occurred:send_codeis capped despite answering 200 to everythingFull API suite: 155 passed.
Full API suite: 152 passed.
The numbers, resized so a classroom can't reach them
The limits in this file had never met real traffic, because they never ran. Login was
20 per hourper IP, and a class of 60 behind one school NAT is a single bucket — most of the room would have been turned away with a 429 they could not clear except by waiting out the window. Schools behind NAT are the core user base, so enabling the limits as written would have hit them first and hardest.Login and password reset are now charged only for requests the endpoint rejected. Correct sign-ins cost nothing. That single change decouples the ceiling from crowd size: a room of any size signing in normally never touches the limit, so the number can stay low enough to still mean something.
get_session,get_anon_sessionreset_password(submit a code)send_code(request a code)add_user,add_basic_useradd_anon_usersearch_users1000 failed attempts an hour from one address is not something a school produces; it is what one host sitting there guessing looks like.
send_codeis the exception that proves the rule. It deliberately answers"OK"even for addresses that don't exist, so it can't be used to enumerate users — which means charging it on failures would charge it nothing, leaving the limit decorative and the endpoint loopable as an email bomb aimed at whoever owns the address, on our SMTP bill. Guessing surfaces (login, submitting a code) count failures; an endpoint whose success is the costly act counts everything.What this does and doesn't buy
An IP bucket honestly caps exactly one thing: a single host hammering. Anyone willing to rotate addresses — residential proxies, an IPv6 /64 — walks around it no matter what the number is. The defence that actually stops distributed brute force is a per-account limit keyed on the email already present in
/session/<email>, so that sixty students are sixty buckets while an attacker hammering one account stays capped regardless of how many addresses they own. That is not in this PR; it's the obvious follow-up, and it's the piece that would let the per-IP number stay generous forever.send_codeis also keyed on the address in its own route, not just on the caller. A per-IP number can't stop an email bomb, because the caller can move; the inbox can't. Five an hour to any one address, from anywhere, and nobody legitimate needs a sixth. The per-IP limit stays underneath as a backstop against spraying one message at each of many addresses.add_anon_userdeliberately keeps its old 200/hour. It hands itsinvite_codetoUser.create_anonymous, which accepts the argument and never validates it — so unlike the other two creation endpoints, invite codes are not a second line of defence there and this limit is the whole of it. Anonymous accounts need no email, which also makes them the cheapest thing to mass-create.Still open
With 4 gunicorn workers and no shared store, every limit is silently multiplied by up to 4. This PR makes
RATELIMIT_STORAGE_URIreachable for the first time (it was being read before the config was loaded), but nothing is on the other end of it yet, so the numbers above are per-worker.Testing
Six tests, each pinning a failure mode that actually occurred — including the two directions this file had never been tested in at all:
Independent of #724, which carries no limits and is unaffected.
🤖 Generated with Claude Code