Skip to content

Make ratelimits configurable - #3090

Draft
felix-m-c wants to merge 7 commits into
CTFd:masterfrom
felix-m-c:make_ratelimits_configurable
Draft

Make ratelimits configurable#3090
felix-m-c wants to merge 7 commits into
CTFd:masterfrom
felix-m-c:make_ratelimits_configurable

Conversation

@felix-m-c

@felix-m-c felix-m-c commented Jul 31, 2026

Copy link
Copy Markdown

Summary:

currently, it is not possible to configure or disable the ratelimit system.
This poses an issue for our events where many (30+) users may share a single IP address.
In these cases features like flag submission immediately run into rate limits for all users.
This issue was previously discussed in #1091, but did not lead to a PR at the time.

Change:

This PR moves the configuration of rate limits from the sourcecode to the config.ini file. (excerpt see below)
It also adds a new RL_DISABLE_COMPLETELY option that disables ratelimits outright.

Backwards Compatibility:

Plugins using the syntax @ratelimit(method="POST", limit=10, interval=60) still work because I only add (optional!) keyword args. If these Values are provided, the new logic uses them instead of falling back to RL_DEFAULT.
The decorator @ratelimit(method="POST") now uses the configured default ratelimit.

Note that the RL_DISABLE_COMPLETELY config option overrides the ratelimit decorator for exiing plugins too.

...
[ratelimits]
# RL_DISABLE_COMPLETELY
# Controls whether the ratelimit functionality is used at all.
# setting this to true overrides all RL settings below
RL_DISABLE_COMPLETELY = false

# All of the following RL configs follow the same format
# format: key = limit,interval
# users may send <limit> calls in <interval> seconds (both int!)

# Fallback ratelimit
#   - if the given key was not found in this config
#   - and if the given key has no built-in default in config.py (backwards compat)
RL_DEFAULT = 10,60
# POST to /confirm and /confirm/data
RL_CONFIRM = 10,60
# POST to /reset_password and /reset_password/<data>
RL_RESET_PASSWORD = 10,60
# POST to /register
RL_REGISTER = 10,5
# POST to /login
RL_LOGIN = 10,5
# GET /redirect
RL_OAUTH_REDIRECT = 10,60
# POST to /teams/join
RL_JOIN_TEAM = 10,5
# POST to the exports/raw endpoint
RL_EXPORT = 10,60
# POST to users/<userid>/email (sending an email to a user)
RL_EMAIL_USER = 10,60
# GET /events
RL_EVENTS = 150,60
...

@jusito

jusito commented Jul 31, 2026

Copy link
Copy Markdown

@felix-m-c and I discussed about this.

  • For us ratelimit on / off is sufficient.
  • Individual options seemed to be a good follow up to be accepted, but we are unsure about the use-cases.
  • ip & ip range whitelisting was also discussed but it requires more code. AI produced something which seems good but we would need to review it in depth.
  • Is config only fine, do we need an admin route, and / or admin ui option?

@ColdHeat

ColdHeat commented Aug 1, 2026

Copy link
Copy Markdown
Member

Hello, I will need to think about whether these should be configured from code config or UI config but I'm more curious about the underlying issue. I believe that the ratelimit applies to IP address and code so what was the issue caused by the shared IP? Were all of the users also sharing the same account?

This poses an issue for our events where many (30+) users may share a single IP address.

@jusito

jusito commented Aug 2, 2026

Copy link
Copy Markdown

Hello @ColdHeat , thanks for the quick reply, all our cases use different accounts.

  1. The mentioned event was a shared workstation, different accounts.
  2. We are doing E2E testing via playwright, each client registers its own account.

@felix-m-c

Copy link
Copy Markdown
Author

The Ratelimiter uses the Cache System to track the number of accesses over time.
The Cache Key is defined as key = "{}:{}:{}".format(key_prefix, ip_address, request.endpoint) (key_prefix is always "rl").
The access-counter is therefore identified by (ip, endpoint).

Not tying access counters to usernames/emails makes sense because many rate-limited endpoints would be called before login. (/login, /register, /reset_password, /redirect)

For some of these, we could add another identifier to the cache key, but that would be some user-provided (untrusted) string that someone trying to overwhelm the system could just randomize anyways. (e.g., the requested email adress for /reset_password).

(i would also like to mention here that the rate-limiting logic does not limit users to N accesses over M seconds, but instead it counts until N and only resets if a gap of M seconds occurred in between. E.g., 1 access every 50 seconds would trigger a (limit=10, interval=60) configuration after 500 Seconds)

@ColdHeat

Copy link
Copy Markdown
Member

I agree that this should be implemented via config.ini/config.py.

The names of the variables should be RATELIMIT and not RL. Instead of RL_DISABLE_COMPLETELY it should be RATELIMIT_DISABLE.

Also instead of hardcoding the values in config.ini I think it would be better to set them in config.py so that it's easier for an envvar to override.

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.

3 participants