Skip to content

Commit

Permalink
Meta limits to track and act on repeated rate limit breaches (#418)
Browse files Browse the repository at this point in the history
  • Loading branch information
alisaifee authored Aug 31, 2023
1 parent 0911d87 commit 3e7a455
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 4 deletions.
14 changes: 14 additions & 0 deletions doc/source/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,20 @@ take priority.

- A function that will be called when any limit in this
extension is breached.
* - .. data:: RATELIMIT_META

Constructor argument: :paramref:`~flask_limiter.Limiter.meta_limits`

- A comma (or some other delimiter) separated string that will be used to
control the upper limit of a requesting client hitting any configured rate limit.
Once a meta limit is exceeded all subsequent requests will raise a
:class:`~flask_limiter.RateLimitExceeded` for the duration of the meta limit window.
* - .. data:: RATELIMIT_ON_META_BREACH_CALLBACK

Constructor argument: :paramref:`~flask_limiter.Limiter.on_meta_breach_callback`

- A function that will be called when a meta limit in this
extension is breached.

.. _ratelimit-string:

Expand Down
83 changes: 83 additions & 0 deletions doc/source/recipes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,89 @@ response from :paramref:`Limiter.limiter.on_breach` callback (if provided) will
take priority over the response from the :paramref:`Limiter.on_breach` callback if
there is one.

Meta limits
-----------
.. versionadded:: 3.5.0

Meta limits can be used for an additional layer of protection (for example
against denial of service attacks) by limiting the number of times a requesting
client can hit any rate limit in the application within configured time slices.

These can be configured by using the :paramref:`~flask_limiter.Limiter.meta_limits`
constructor argument (or the associated :data:`RATELIMIT_META` flask
config attribute).


Consider the following application & limiter configuration::

app = Limiter(
key_func=get_remote_address,
meta_limits=["2/hour", "4/day"],
default_limits=["10/minute"],
)

@app.route("/fast")
def fast():
return "fast"

@app.route("/slow")
@limiter.limit("1/minute")
def slow():
return "slow"


The ``2/hour, 4/day`` value of :paramref:`~flask_limiter.Limiter.meta_limits` ensures that if
any of the ``default_limits`` or per route limit of ``1/minute`` is exceeded more than
**twice an hour** or **four times a day**, a :class:`~flask_limiter.RateLimitExceeded` exception will be
raised (i.e. a ``429`` response will be returned) for any subsequent request until the ``meta_limit`` is reset.

For example

.. code-block:: shell
$ curl localhost:5000/fast
fast
$ curl localhost:5000/slow
slow
$ curl localhost:5000/slow
<!doctype html>
<html lang=en>
<title>429 Too Many Requests</title>
<h1>Too Many Requests</h1>
<p>1 per 1 minute</p>
After a minute the ``slow`` endpoint can be accessed again once per minute

.. code-block:: shell
$ sleep 60
$ curl localhost:5000/slow
slow
$ curl localhost:5000/slow
<!doctype html>
<html lang=en>
<title>429 Too Many Requests</title>
<h1>Too Many Requests</h1>
<p>1 per 1 minute</p>
Now, even after waiting a minute both the ``slow`` and ``fast`` endpoints
are rejected due to the ``2/hour`` meta limit.

.. code-block:: shell
$ sleep 60
$ curl localhost:5000/slow
<!doctype html>
<html lang=en>
<title>429 Too Many Requests</title>
<h1>Too Many Requests</h1>
<p>2 per 1 hour</p>
$ curl localhost:5000/fast
<!doctype html>
<html lang=en>
<title>429 Too Many Requests</title>
<h1>Too Many Requests</h1>
<p>2 per 1 hour</p>
Customizing the cost of a request
---------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/kitchensink.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def default_cost():
default_limits_deduct_when=lambda response: response.status_code == 200,
default_limits_cost=default_cost,
application_limits=["5000/hour"],
meta_limits=["2/day"],
headers_enabled=True,
storage_uri=os.environ.get("FLASK_RATELIMIT_STORAGE_URI", "memory:https://"),
)
Expand Down
20 changes: 20 additions & 0 deletions flask_limiter/commands.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import itertools
import time
from functools import partial
from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union
Expand Down Expand Up @@ -301,6 +302,17 @@ def config() -> None:
"Default Limits", ConfigVars.DEFAULT_LIMITS, Pretty([])
)

if limiter._meta_limits:
extension_details.add_row(
"Meta Limits",
ConfigVars.META_LIMITS,
Pretty(
[
render_limit(limit)
for limit in itertools.chain(*limiter._meta_limits)
]
),
)
if limiter._headers_enabled:
header_configs = Tree(ConfigVars.HEADERS_ENABLED)
header_configs.add(ConfigVars.HEADER_RESET)
Expand Down Expand Up @@ -422,6 +434,14 @@ def console_renderable() -> Generator: # type: ignore
and limiter.limit_manager.application_limits
and not (endpoint or path)
):
yield render_limits(
current_app,
limiter,
(list(itertools.chain(*limiter._meta_limits)), []),
test=key,
method=method,
label="[gold3]Meta Limits[/gold3]",
)
yield render_limits(
current_app,
limiter,
Expand Down
2 changes: 2 additions & 0 deletions flask_limiter/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class ConfigVars:
HEADER_RETRY_AFTER_VALUE = "RATELIMIT_HEADER_RETRY_AFTER_VALUE"
IN_MEMORY_FALLBACK = "RATELIMIT_IN_MEMORY_FALLBACK"
IN_MEMORY_FALLBACK_ENABLED = "RATELIMIT_IN_MEMORY_FALLBACK_ENABLED"
META_LIMITS = "RATELIMIT_META"
ON_META_BREACH = "RATELIMIT_ON_META_BREACH_CALLBACK"


class HeaderNames(enum.Enum):
Expand Down
75 changes: 75 additions & 0 deletions flask_limiter/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ class Limiter:
extension is breached. If the function returns an instance of :class:`flask.Response`
that will be the response embedded into the :exc:`RateLimitExceeded` exception
raised.
:param meta_limits: a variable list of strings or callables
returning strings for limits that are used to control the upper limit of
a requesting client hitting any configured rate limit. Once a meta limit is
exceeded all subsequent requests will raise a :class:`~flask_limiter.RateLimitExceeded`
for the duration of the meta limit window.
:param on_meta_breach: a function that will be called when a meta limit in this
extension is breached. If the function returns an instance of :class:`flask.Response`
that will be the response embedded into the :exc:`RateLimitExceeded` exception
raised.
:param in_memory_fallback: a variable list of strings or callables
returning strings denoting fallback limits to apply when the storage is
down.
Expand Down Expand Up @@ -159,6 +168,10 @@ def __init__(
on_breach: Optional[
Callable[[RequestLimit], Optional[flask.wrappers.Response]]
] = None,
meta_limits: Optional[List[Union[str, Callable[[], str]]]] = None,
on_meta_breach: Optional[
Callable[[RequestLimit], Optional[flask.wrappers.Response]]
] = None,
in_memory_fallback: Optional[List[str]] = None,
in_memory_fallback_enabled: Optional[bool] = None,
retry_after: Optional[str] = None,
Expand Down Expand Up @@ -201,6 +214,7 @@ def __init__(
self._swallow_errors = swallow_errors
self._fail_on_first_breach = fail_on_first_breach
self._on_breach = on_breach
self._on_meta_breach = on_meta_breach

self._key_func = key_func
self._key_prefix = key_prefix
Expand Down Expand Up @@ -232,6 +246,20 @@ def __init__(
else []
)

self._meta_limits = (
[
LimitGroup(
limit_provider=limit,
key_function=self._key_func,
scope="meta",
shared=True,
)
for limit in meta_limits
]
if meta_limits
else []
)

if in_memory_fallback:
for limit in in_memory_fallback:
self._in_memory_fallback.append(
Expand Down Expand Up @@ -420,6 +448,23 @@ def init_app(self, app: flask.Flask) -> None:
group.deduct_when = self._default_limits_deduct_when
group.cost = self._default_limits_cost
self.limit_manager.set_default_limits(default_limit_groups)

meta_limits = config.get(ConfigVars.META_LIMITS, None)
if not self._meta_limits and meta_limits:
self._meta_limits = [
LimitGroup(
limit_provider=meta_limits,
key_function=self._key_func,
scope="meta",
shared=True,
)
]

self._on_breach = self._on_breach or config.get(ConfigVars.ON_BREACH, None)
self._on_meta_breach = self._on_meta_breach or config.get(
ConfigVars.ON_META_BREACH, None
)

self.__configure_fallbacks(app, self._strategy)

if self not in app.extensions.setdefault("limiter", set()):
Expand Down Expand Up @@ -984,6 +1029,31 @@ def __evaluate_limits(self, endpoint: str, limits: List[Limit]) -> None:
failed_limits: List[Tuple[Limit, List[str]]] = []
limit_for_header: Optional[RequestLimit] = None
view_limits: List[RequestLimit] = []
meta_limits = list(itertools.chain(*self._meta_limits))
for lim in meta_limits:
limit_key, scope = lim.key_func(), lim.scope_for(endpoint, None)
args = [limit_key, scope]
if not self.limiter.test(lim.limit, *args):
breached_meta_limit = RequestLimit(
self, lim.limit, args, True, lim.shared
)
self.context.view_rate_limit = breached_meta_limit
self.context.view_rate_limits = [breached_meta_limit]
meta_breach_response = None
if self._on_meta_breach:
try:
cb_response = self._on_meta_breach(breached_meta_limit)
if isinstance(cb_response, flask.wrappers.Response):
meta_breach_response = cb_response
except Exception as err: # noqa
if self._swallow_errors:
self.logger.exception(
"on_meta_breach callback failed with error %s", err
)
else:
raise err
raise RateLimitExceeded(lim, response=meta_breach_response)

for lim in sorted(limits, key=lambda x: x.limit):
if lim.is_exempt or lim.method_exempt:
continue
Expand Down Expand Up @@ -1055,6 +1125,11 @@ def __evaluate_limits(self, endpoint: str, limits: List[Limit]) -> None:
else:
raise err
if failed_limits:
for lim in meta_limits:
limit_scope = lim.scope_for(endpoint, flask.request.method)
limit_key = lim.key_func()
args = [limit_key, limit_scope]
self.limiter.hit(lim.limit, *args)
raise RateLimitExceeded(
sorted(failed_limits, key=lambda x: x[0].limit)[0][0],
response=on_breach_response,
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def dynamic_default_cost():
default_limits_deduct_when=lambda response: response.status_code != 200,
default_limits_cost=dynamic_default_cost,
application_limits=["5000/hour"],
meta_limits=["2/day"],
headers_enabled=True,
**kwargs
)
Expand Down
19 changes: 16 additions & 3 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import math
import time

import hiro
import pytest
from flask import Flask
from limits.errors import ConfigurationError
from limits.storage import MemcachedStorage
from limits.storage import MemoryStorage
from limits.strategies import MovingWindowRateLimiter

from flask_limiter import HeaderNames
Expand Down Expand Up @@ -34,10 +35,22 @@ def test_constructor_arguments_over_config(redis_connection):
limiter.init_app(app)
app.config.setdefault(ConfigVars.STORAGE_URI, "redis:https://localhost:46379")
app.config.setdefault(ConfigVars.APPLICATION_LIMITS, "1/minute")
app.config.setdefault(ConfigVars.META_LIMITS, "1/hour")
assert type(limiter._limiter) == MovingWindowRateLimiter
limiter = Limiter(get_remote_address, storage_uri="memcached:https://localhost:31211")
limiter = Limiter(get_remote_address, storage_uri="memory:https://")
limiter.init_app(app)
assert type(limiter._storage) == MemcachedStorage
assert type(limiter._storage) == MemoryStorage

@app.route("/")
def root():
return "root"

with hiro.Timeline().freeze() as timeline:
with app.test_client() as cli:
assert cli.get("/").status_code == 200
assert cli.get("/").status_code == 429
timeline.forward(60)
assert cli.get("/").status_code == 429


def test_header_names_config():
Expand Down
Loading

0 comments on commit 3e7a455

Please sign in to comment.