Skip to content

Make the rate limits actually run - #725

Merged
mircealungu merged 8 commits into
masterfrom
fix-inert-rate-limits
Sep 18, 2026
Merged

mircealungu merged 8 commits into
masterfrom
fix-inert-rate-limits

Conversation

@mircealungu

@mircealungu mircealungu commented Sep 8, 2026

Copy link
Copy Markdown
Member

None of the rate limits have ever been enforced

RATE_LIMITS keys every entry as api.<view>. The blueprint is declared as flask.Blueprint("endpoints", __name__) and merely assigned to a variable named api, so the real endpoint names are endpoints.*. Every lookup returned None:

'api.get_session'      -> None
'api.get_anon_session' -> None
'api.send_code'        -> None
'api.reset_password'   -> None
'api.add_user'         -> None
'api.add_basic_user'   -> None
'api.add_anon_user'    -> None

limiter.limit(...) was then handed None, threw, and a bare except Exception: pass swallowed it at startup. Nothing logged. On master, 15 rapid failed logins against an endpoint documented as 5 per minute:

status codes: [401, 401, 401, 401, 401, 401, 401, 401, 401, 401, 401, 401, 401, 401, 401]
any 429? False

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 resolves endpoints.get_session to the undecorated function still sitting in app.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 RuntimeError at startup instead of a silent skip:

Rate limits configured for unknown endpoints: api.get_session. Endpoint names
are '<blueprint>.<view function>' and this app's blueprint is named 'endpoints'.

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_limiter ran before load_configuration_or_abort, so app.config.get("RATELIMIT_STORAGE_URI") always fell through to memory://. 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.
  • The limiter is disabled under 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_users accepts 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 way requires_session does 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:

  • every configured endpoint name resolves
  • 15 rapid logins produce a 429
  • 70 rapid searches produce a 429
  • exhausting one session's search quota does not lock out a second session
  • send_code is capped despite answering 200 to everything

Full 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 hour per 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.

Endpoint Was (per IP) Now (per IP) Charged for
get_session, get_anon_session 5/min, 20/hour 100/min, 1000/hour rejected attempts only
reset_password (submit a code) 3/min, 10/hour 20/min, 200/hour rejected attempts only
send_code (request a code) 3/min, 10/hour 5/hour per target address, plus 20/min, 200/hour per IP every request
add_user, add_basic_user 100/hour 500/hour all
add_anon_user 200/hour 200/hour (unchanged) all
search_users 60/min, 600/hour per session all

1000 failed attempts an hour from one address is not something a school produces; it is what one host sitting there guessing looks like.

send_code is 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_code is 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_user deliberately keeps its old 200/hour. It hands its invite_code to User.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_URI reachable 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:

  • every configured endpoint name resolves
  • 105 rapid login failures produce a 429
  • 70 rapid searches produce a 429
  • 60 legitimate logins from one address produce no 429 (the classroom case)
  • 150 correct sign-ins do not consume the failure budget
  • exhausting one session's search quota does not lock out a second session

Independent of #724, which carries no limits and is unaffected.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

ArchLens - No architecturally relevant changes to the existing views

@mircealungu
mircealungu marked this pull request as draft September 8, 2026 09:38
mircealungu and others added 5 commits September 18, 2026 00:03
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 and others added 3 commits September 18, 2026 00:54
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>
@mircealungu
mircealungu marked this pull request as ready for review September 18, 2026 14:40
@mircealungu
mircealungu merged commit abb5c09 into master Sep 18, 2026
3 checks passed
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.

1 participant