Merge branch 'rate-limiting' into 'develop'

: Rate limiting

Closes #261

See merge request funkwhale/funkwhale!877
This commit is contained in:
Eliot Berriot 2019-09-17 11:23:59 +02:00
commit 853cd833b5
20 changed files with 1077 additions and 19 deletions

View File

@ -2,7 +2,6 @@ from django.conf.urls import include, url
from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
from rest_framework import routers
from rest_framework.urlpatterns import format_suffix_patterns
from rest_framework_jwt import views as jwt_views
from funkwhale_api.activity import views as activity_views
from funkwhale_api.common import views as common_views
@ -11,6 +10,7 @@ from funkwhale_api.music import views
from funkwhale_api.playlists import views as playlists_views
from funkwhale_api.subsonic.views import SubsonicViewSet
from funkwhale_api.tags import views as tags_views
from funkwhale_api.users import jwt_views
router = common_routers.OptionalSlashRouter()
router.register(r"settings", GlobalPreferencesViewSet, basename="settings")
@ -83,6 +83,7 @@ v1_patterns += [
),
url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"),
url(r"^token/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"),
url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
]
urlpatterns = [

View File

@ -232,6 +232,7 @@ MIDDLEWARE = (
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"funkwhale_api.users.middleware.RecordActivityMiddleware",
"funkwhale_api.common.middleware.ThrottleStatusMiddleware",
)
# DEBUG
@ -615,7 +616,150 @@ REST_FRAMEWORK = {
"django_filters.rest_framework.DjangoFilterBackend",
),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"NUM_PROXIES": env.int("NUM_PROXIES", default=1),
}
THROTTLING_ENABLED = env.bool("THROTTLING_ENABLED", default=True)
if THROTTLING_ENABLED:
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = env.list(
"THROTTLE_CLASSES",
default=["funkwhale_api.common.throttling.FunkwhaleThrottle"],
)
THROTTLING_SCOPES = {
"*": {"anonymous": "anonymous-wildcard", "authenticated": "authenticated-wildcard"},
"create": {
"authenticated": "authenticated-create",
"anonymous": "anonymous-create",
},
"list": {"authenticated": "authenticated-list", "anonymous": "anonymous-list"},
"retrieve": {
"authenticated": "authenticated-retrieve",
"anonymous": "anonymous-retrieve",
},
"destroy": {
"authenticated": "authenticated-destroy",
"anonymous": "anonymous-destroy",
},
"update": {
"authenticated": "authenticated-update",
"anonymous": "anonymous-update",
},
"partial_update": {
"authenticated": "authenticated-update",
"anonymous": "anonymous-update",
},
}
THROTTLING_USER_RATES = env.dict("THROTTLING_RATES", default={})
THROTTLING_RATES = {
"anonymous-wildcard": {
"rate": THROTTLING_USER_RATES.get("anonymous-wildcard", "1000/h"),
"description": "Anonymous requests not covered by other limits",
},
"authenticated-wildcard": {
"rate": THROTTLING_USER_RATES.get("authenticated-wildcard", "2000/h"),
"description": "Authenticated requests not covered by other limits",
},
"authenticated-create": {
"rate": THROTTLING_USER_RATES.get("authenticated-create", "1000/hour"),
"description": "Authenticated POST requests",
},
"anonymous-create": {
"rate": THROTTLING_USER_RATES.get("anonymous-create", "1000/day"),
"description": "Anonymous POST requests",
},
"authenticated-list": {
"rate": THROTTLING_USER_RATES.get("authenticated-list", "10000/hour"),
"description": "Authenticated GET requests on resource lists",
},
"anonymous-list": {
"rate": THROTTLING_USER_RATES.get("anonymous-list", "10000/day"),
"description": "Anonymous GET requests on resource lists",
},
"authenticated-retrieve": {
"rate": THROTTLING_USER_RATES.get("authenticated-retrieve", "10000/hour"),
"description": "Authenticated GET requests on resource detail",
},
"anonymous-retrieve": {
"rate": THROTTLING_USER_RATES.get("anonymous-retrieve", "10000/day"),
"description": "Anonymous GET requests on resource detail",
},
"authenticated-destroy": {
"rate": THROTTLING_USER_RATES.get("authenticated-destroy", "500/hour"),
"description": "Authenticated DELETE requests on resource detail",
},
"anonymous-destroy": {
"rate": THROTTLING_USER_RATES.get("anonymous-destroy", "1000/day"),
"description": "Anonymous DELETE requests on resource detail",
},
"authenticated-update": {
"rate": THROTTLING_USER_RATES.get("authenticated-update", "1000/hour"),
"description": "Authenticated PATCH and PUT requests on resource detail",
},
"anonymous-update": {
"rate": THROTTLING_USER_RATES.get("anonymous-update", "1000/day"),
"description": "Anonymous PATCH and PUT requests on resource detail",
},
# potentially spammy / dangerous endpoints
"authenticated-reports": {
"rate": THROTTLING_USER_RATES.get("authenticated-reports", "100/day"),
"description": "Authenticated report submission",
},
"anonymous-reports": {
"rate": THROTTLING_USER_RATES.get("anonymous-reports", "10/day"),
"description": "Anonymous report submission",
},
"authenticated-oauth-app": {
"rate": THROTTLING_USER_RATES.get("authenticated-oauth-app", "10/hour"),
"description": "Authenticated OAuth app creation",
},
"anonymous-oauth-app": {
"rate": THROTTLING_USER_RATES.get("anonymous-oauth-app", "10/day"),
"description": "Anonymous OAuth app creation",
},
"oauth-authorize": {
"rate": THROTTLING_USER_RATES.get("oauth-authorize", "100/hour"),
"description": "OAuth app authorization",
},
"oauth-token": {
"rate": THROTTLING_USER_RATES.get("oauth-token", "100/hour"),
"description": "OAuth token creation",
},
"oauth-revoke-token": {
"rate": THROTTLING_USER_RATES.get("oauth-revoke-token", "100/hour"),
"description": "OAuth token deletion",
},
"jwt-login": {
"rate": THROTTLING_USER_RATES.get("jwt-login", "30/hour"),
"description": "JWT token creation",
},
"jwt-refresh": {
"rate": THROTTLING_USER_RATES.get("jwt-refresh", "30/hour"),
"description": "JWT token refresh",
},
"signup": {
"rate": THROTTLING_USER_RATES.get("signup", "10/day"),
"description": "Account creation",
},
"verify-email": {
"rate": THROTTLING_USER_RATES.get("verify-email", "20/h"),
"description": "Email address confirmation",
},
"password-change": {
"rate": THROTTLING_USER_RATES.get("password-change", "20/h"),
"description": "Password change (when authenticated)",
},
"password-reset": {
"rate": THROTTLING_USER_RATES.get("password-reset", "20/h"),
"description": "Password reset request",
},
"password-reset-confirm": {
"rate": THROTTLING_USER_RATES.get("password-reset-confirm", "20/h"),
"description": "Password reset confirmation",
},
}
BROWSABLE_API_ENABLED = env.bool("BROWSABLE_API_ENABLED", default=False)
if BROWSABLE_API_ENABLED:

View File

@ -19,8 +19,7 @@ urlpatterns = [
("funkwhale_api.federation.urls", "federation"), namespace="federation"
),
),
url(r"^api/v1/auth/", include("rest_auth.urls")),
url(r"^api/v1/auth/registration/", include("funkwhale_api.users.rest_auth_urls")),
url(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")),
url(r"^accounts/", include("allauth.urls")),
# Your stuff: custom urls includes go here
]

View File

@ -1,13 +1,16 @@
import html
import requests
import time
import xml.sax.saxutils
from django import http
from django.conf import settings
from django.core.cache import caches
from django import urls
from rest_framework import views
from . import preferences
from . import throttling
from . import utils
EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"]
@ -176,3 +179,66 @@ class DevHttpsMiddleware:
lambda: request.__class__.get_host(request).replace(":80", ":443"),
)
return self.get_response(request)
def monkey_patch_rest_initialize_request():
"""
Rest framework use it's own APIRequest, meaning we can't easily
access our throttling info in the middleware. So me monkey patch the
`initialize_request` method from rest_framework to keep a link between both requests
"""
original = views.APIView.initialize_request
def replacement(self, request, *args, **kwargs):
r = original(self, request, *args, **kwargs)
setattr(request, "_api_request", r)
return r
setattr(views.APIView, "initialize_request", replacement)
monkey_patch_rest_initialize_request()
class ThrottleStatusMiddleware:
"""
Include useful information regarding throttling in API responses to
ensure clients can adapt.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
response = self.get_response(request)
except throttling.TooManyRequests:
# manual throttling in non rest_framework view, we have to return
# the proper response ourselves
response = http.HttpResponse(status=429)
request_to_check = request
try:
request_to_check = request._api_request
except AttributeError:
pass
throttle_status = getattr(request_to_check, "_throttle_status", None)
if throttle_status:
response["X-RateLimit-Limit"] = str(throttle_status["num_requests"])
response["X-RateLimit-Scope"] = str(throttle_status["scope"])
response["X-RateLimit-Remaining"] = throttle_status["num_requests"] - len(
throttle_status["history"]
)
response["X-RateLimit-Duration"] = str(throttle_status["duration"])
if throttle_status["history"]:
now = int(time.time())
# At this point, the client can send additional requests
oldtest_request = throttle_status["history"][-1]
remaining = throttle_status["duration"] - (now - int(oldtest_request))
response["Retry-After"] = str(remaining)
# At this point, all Rate Limit is reset to 0
latest_request = throttle_status["history"][0]
remaining = throttle_status["duration"] - (now - int(latest_request))
response["X-RateLimit-Reset"] = str(now + remaining)
response["X-RateLimit-ResetSeconds"] = str(remaining)
return response

View File

@ -0,0 +1,151 @@
import collections
from django.core.cache import cache
from rest_framework import throttling as rest_throttling
from django.conf import settings
def get_ident(request):
if hasattr(request, "user") and request.user.is_authenticated:
return {"type": "authenticated", "id": request.user.pk}
ident = rest_throttling.BaseThrottle().get_ident(request)
return {"type": "anonymous", "id": ident}
def get_cache_key(scope, ident):
parts = ["throttling", scope, ident["type"], str(ident["id"])]
return ":".join(parts)
def get_scope_for_action_and_ident_type(action, ident_type, view_conf={}):
config = collections.ChainMap(view_conf, settings.THROTTLING_SCOPES)
try:
action_config = config[action]
except KeyError:
action_config = config.get("*", {})
try:
return action_config[ident_type]
except KeyError:
return
def get_status(ident, now):
data = []
throttle = FunkwhaleThrottle()
for key in sorted(settings.THROTTLING_RATES.keys()):
conf = settings.THROTTLING_RATES[key]
row_data = {"id": key, "rate": conf["rate"], "description": conf["description"]}
if conf["rate"]:
num_requests, duration = throttle.parse_rate(conf["rate"])
history = cache.get(get_cache_key(key, ident)) or []
relevant_history = [h for h in history if h > now - duration]
row_data["limit"] = num_requests
row_data["duration"] = duration
row_data["remaining"] = num_requests - len(relevant_history)
if relevant_history and len(relevant_history) >= num_requests:
# At this point, the endpoint becomes available again
now_request = relevant_history[-1]
remaining = duration - (now - int(now_request))
row_data["available"] = int(now + remaining) or None
row_data["available_seconds"] = int(remaining) or None
else:
row_data["available"] = None
row_data["available_seconds"] = None
if relevant_history:
# At this point, all Rate Limit is reset to 0
latest_request = relevant_history[0]
remaining = duration - (now - int(latest_request))
row_data["reset"] = int(now + remaining)
row_data["reset_seconds"] = int(remaining)
else:
row_data["reset"] = None
row_data["reset_seconds"] = None
else:
row_data["limit"] = None
row_data["duration"] = None
row_data["remaining"] = None
row_data["available"] = None
row_data["available_seconds"] = None
row_data["reset"] = None
row_data["reset_seconds"] = None
data.append(row_data)
return data
class FunkwhaleThrottle(rest_throttling.SimpleRateThrottle):
def __init__(self):
pass
def get_cache_key(self, request, view):
return get_cache_key(self.scope, self.ident)
def allow_request(self, request, view):
self.request = request
self.ident = get_ident(request)
action = getattr(view, "action", "*")
view_scopes = getattr(view, "throttling_scopes", {})
if view_scopes is None:
return True
self.scope = get_scope_for_action_and_ident_type(
action=action, ident_type=self.ident["type"], view_conf=view_scopes
)
if not self.scope or self.scope not in settings.THROTTLING_RATES:
return True
self.rate = settings.THROTTLING_RATES[self.scope].get("rate")
self.num_requests, self.duration = self.parse_rate(self.rate)
self.request = request
return super().allow_request(request, view)
def attach_info(self):
info = {
"num_requests": self.num_requests,
"duration": self.duration,
"scope": self.scope,
"history": self.history or [],
"wait": self.wait(),
}
setattr(self.request, "_throttle_status", info)
def throttle_success(self):
self.attach_info()
return super().throttle_success()
def throttle_failure(self):
self.attach_info()
return super().throttle_failure()
class TooManyRequests(Exception):
pass
DummyView = collections.namedtuple("DummyView", "action throttling_scopes")
def check_request(request, scope):
"""
A simple wrapper around FunkwhaleThrottle for views that aren't API views
or cannot use rest_framework automatic throttling.
Raise TooManyRequests if limit is reached.
"""
if not settings.THROTTLING_ENABLED:
return True
view = DummyView(
action=scope,
throttling_scopes={scope: {"anonymous": scope, "authenticated": scope}},
)
throttle = FunkwhaleThrottle()
if not throttle.allow_request(request, view):
raise TooManyRequests()
return True

View File

@ -1,3 +1,6 @@
import time
from django.conf import settings
from django.db import transaction
from rest_framework.decorators import action
@ -5,6 +8,7 @@ from rest_framework import exceptions
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import response
from rest_framework import views
from rest_framework import viewsets
from . import filters
@ -13,6 +17,7 @@ from . import mutations
from . import serializers
from . import signals
from . import tasks
from . import throttling
from . import utils
@ -121,3 +126,17 @@ class MutationViewSet(
new_is_approved=instance.is_approved,
)
return response.Response({}, status=200)
class RateLimitView(views.APIView):
permission_classes = []
throttle_classes = []
def get(self, request, *args, **kwargs):
ident = throttling.get_ident(request)
data = {
"enabled": settings.THROTTLING_ENABLED,
"ident": ident,
"scopes": throttling.get_status(ident, time.time()),
}
return response.Response(data, status=200)

View File

@ -49,6 +49,12 @@ class ReportsViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
ordering_fields = ("creation_date",)
anonymous_policy = "setting"
anonymous_scopes = {"write:reports"}
throttling_scopes = {
"create": {
"anonymous": "anonymous-reports",
"authenticated": "authenticated-reports",
}
}
def get_serializer_context(self):
context = super().get_serializer_context()

View File

@ -0,0 +1,15 @@
from rest_framework_jwt import views as jwt_views
class ObtainJSONWebToken(jwt_views.ObtainJSONWebToken):
throttling_scopes = {"*": {"anonymous": "jwt-login", "authenticated": "jwt-login"}}
class RefreshJSONWebToken(jwt_views.RefreshJSONWebToken):
throttling_scopes = {
"*": {"anonymous": "jwt-refresh", "authenticated": "jwt-refresh"}
}
obtain_jwt_token = ObtainJSONWebToken.as_view()
refresh_jwt_token = RefreshJSONWebToken.as_view()

View File

@ -10,6 +10,8 @@ from oauth2_provider import exceptions as oauth2_exceptions
from oauth2_provider import views as oauth_views
from oauth2_provider.settings import oauth2_settings
from funkwhale_api.common import throttling
from .. import models
from .permissions import ScopePermission
from . import serializers
@ -35,6 +37,12 @@ class ApplicationViewSet(
lookup_field = "client_id"
queryset = models.Application.objects.all().order_by("-created")
serializer_class = serializers.ApplicationSerializer
throttling_scopes = {
"create": {
"anonymous": "anonymous-oauth-app",
"authenticated": "authenticated-oauth-app",
}
}
def get_serializer_class(self):
if self.request.method.lower() == "post":
@ -141,6 +149,10 @@ class AuthorizeView(views.APIView, oauth_views.AuthorizationView):
return self.json_payload(errors, status_code=400)
def post(self, request, *args, **kwargs):
throttling.check_request(request, "oauth-authorize")
return super().post(request, *args, **kwargs)
def form_valid(self, form):
try:
response = super().form_valid(form)
@ -175,8 +187,12 @@ class AuthorizeView(views.APIView, oauth_views.AuthorizationView):
class TokenView(oauth_views.TokenView):
pass
def post(self, request, *args, **kwargs):
throttling.check_request(request, "oauth-token")
return super().post(request, *args, **kwargs)
class RevokeTokenView(oauth_views.RevokeTokenView):
pass
def post(self, request, *args, **kwargs):
throttling.check_request(request, "oauth-revoke-token")
return super().post(request, *args, **kwargs)

View File

@ -1,20 +1,40 @@
from django.conf.urls import url
from django.views.generic import TemplateView
from rest_auth import views as rest_auth_views
from rest_auth.registration import views as registration_views
from . import views
urlpatterns = [
url(r"^$", views.RegisterView.as_view(), name="rest_register"),
# URLs that do not require a session or valid token
url(
r"^verify-email/?$",
registration_views.VerifyEmailView.as_view(),
r"^password/reset/$",
views.PasswordResetView.as_view(),
name="rest_password_reset",
),
url(
r"^password/reset/confirm/$",
views.PasswordResetConfirmView.as_view(),
name="rest_password_reset_confirm",
),
# URLs that require a user to be logged in with a valid session / token.
url(
r"^user/$", rest_auth_views.UserDetailsView.as_view(), name="rest_user_details"
),
url(
r"^password/change/$",
views.PasswordChangeView.as_view(),
name="rest_password_change",
),
# Registration URLs
url(r"^registration/$", views.RegisterView.as_view(), name="rest_register"),
url(
r"^registration/verify-email/?$",
views.VerifyEmailView.as_view(),
name="rest_verify_email",
),
url(
r"^change-password/?$",
rest_auth_views.PasswordChangeView.as_view(),
r"^registration/change-password/?$",
views.PasswordChangeView.as_view(),
name="change_password",
),
# This url is used by django-allauth and empty TemplateView is
@ -28,7 +48,7 @@ urlpatterns = [
# view from:
# djang-allauth https://github.com/pennersr/django-allauth/blob/master/allauth/account/views.py#L190
url(
r"^account-confirm-email/(?P<key>\w+)/?$",
r"^registration/account-confirm-email/(?P<key>\w+)/?$",
TemplateView.as_view(),
name="account_confirm_email",
),

View File

@ -1,5 +1,6 @@
from allauth.account.adapter import get_adapter
from rest_auth.registration.views import RegisterView as BaseRegisterView
from rest_auth import views as rest_auth_views
from rest_auth.registration import views as registration_views
from rest_framework import mixins, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
@ -9,9 +10,11 @@ from funkwhale_api.common import preferences
from . import models, serializers
class RegisterView(BaseRegisterView):
class RegisterView(registration_views.RegisterView):
serializer_class = serializers.RegisterSerializer
permission_classes = []
action = "signup"
throttling_scopes = {"signup": {"authenticated": "signup", "anonymous": "signup"}}
def create(self, request, *args, **kwargs):
invitation_code = request.data.get("invitation")
@ -24,6 +27,22 @@ class RegisterView(BaseRegisterView):
return get_adapter().is_open_for_signup(request)
class VerifyEmailView(registration_views.VerifyEmailView):
action = "verify-email"
class PasswordChangeView(rest_auth_views.PasswordChangeView):
action = "password-change"
class PasswordResetView(rest_auth_views.PasswordResetView):
action = "password-reset"
class PasswordResetConfirmView(rest_auth_views.PasswordResetConfirmView):
action = "password-reset-confirm"
class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
queryset = models.User.objects.all()
serializer_class = serializers.UserWriteSerializer

View File

@ -1,6 +1,10 @@
import time
import pytest
from django.http import HttpResponse
from funkwhale_api.common import middleware
from funkwhale_api.common import throttling
def test_spa_fallback_middleware_no_404(mocker):
@ -185,3 +189,39 @@ def test_get_custom_css(preferences, custom_css, expected):
preferences["ui__custom_css"] = custom_css
assert middleware.get_custom_css() == expected
def test_throttle_status_middleware_includes_info_in_response_headers(mocker):
get_response = mocker.Mock()
response = HttpResponse()
get_response.return_value = response
request = mocker.Mock(
path="/",
_api_request=mocker.Mock(
_throttle_status={
"num_requests": 42,
"duration": 3600,
"scope": "hello",
"history": [time.time() - 1600, time.time() - 1800],
}
),
)
m = middleware.ThrottleStatusMiddleware(get_response)
assert m(request) == response
assert response["X-RateLimit-Limit"] == "42"
assert response["X-RateLimit-Remaining"] == "40"
assert response["X-RateLimit-Duration"] == "3600"
assert response["X-RateLimit-Scope"] == "hello"
assert response["X-RateLimit-Reset"] == str(int(time.time()) + 2000)
assert response["X-RateLimit-ResetSeconds"] == str(2000)
assert response["Retry-After"] == str(1800)
def test_throttle_status_middleware_returns_proper_response(mocker):
get_response = mocker.Mock(side_effect=throttling.TooManyRequests())
request = mocker.Mock(path="/", _api_request=None, _throttle_status=None)
m = middleware.ThrottleStatusMiddleware(get_response)
response = m(request)
assert response.status_code == 429

View File

@ -0,0 +1,337 @@
import time
import pytest
from funkwhale_api.common import throttling
def test_get_ident_anonymous(api_request):
ip = "92.92.92.92"
request = api_request.get("/", HTTP_X_FORWARDED_FOR=ip)
expected = {"id": ip, "type": "anonymous"}
assert throttling.get_ident(request) == expected
def test_get_ident_authenticated(api_request, factories):
user = factories["users.User"]()
request = api_request.get("/")
setattr(request, "user", user)
expected = {"id": user.pk, "type": "authenticated"}
assert throttling.get_ident(request) == expected
@pytest.mark.parametrize(
"scope, ident, expected",
[
(
"create",
{"id": 42, "type": "authenticated"},
"throttling:create:authenticated:42",
),
(
"list",
{"id": "92.92.92.92", "type": "anonymous"},
"throttling:list:anonymous:92.92.92.92",
),
],
)
def test_get_cache_key(scope, ident, expected):
assert throttling.get_cache_key(scope, ident) == expected
@pytest.mark.parametrize(
"action, type, view_conf, throttling_actions, expected",
[
# exact match, we return the rate
("retrieve", "anonymous", {}, {"retrieve": {"anonymous": "test"}}, "test"),
# exact match on the view, we return the rate
("retrieve", "anonymous", {"retrieve": {"anonymous": "test"}}, {}, "test"),
# no match, we return nothing
("retrieve", "authenticated", {}, {}, None),
("retrieve", "authenticated", {}, {"retrieve": {"anonymous": "test"}}, None),
(
"retrieve",
"authenticated",
{"destroy": {"authenticated": "test"}},
{"retrieve": {"anonymous": "test"}},
None,
),
# exact match on the view, and in the settings, the view is more important
(
"retrieve",
"anonymous",
{"retrieve": {"anonymous": "test"}},
{"retrieve": {"anonymous": "test-2"}},
"test",
),
# wildcard match, we return the wildcard value
("retrieve", "authenticated", {}, {"*": {"authenticated": "test"}}, "test"),
# wildcard match, but more specific match also, we use this one instead
(
"retrieve",
"authenticated",
{},
{"retrieve": {"authenticated": "test-2"}, "*": {"authenticated": "test"}},
"test-2",
),
],
)
def test_get_rate_for_scope_and_ident_type(
action, type, view_conf, throttling_actions, expected, settings
):
settings.THROTTLING_SCOPES = throttling_actions
assert (
throttling.get_scope_for_action_and_ident_type(
action=action, ident_type=type, view_conf=view_conf
)
is expected
)
@pytest.mark.parametrize(
"view_args, throttling_rates, previous_requests, expected",
[
# room for one more requests
(
{
"action": "retrieve",
"throttling_scopes": {"retrieve": {"anonymous": "test"}},
},
{"test": {"rate": "3/s"}},
2,
True,
),
# number of requests exceeded
(
{
"action": "retrieve",
"throttling_scopes": {"retrieve": {"anonymous": "test"}},
},
{"test": {"rate": "3/s"}},
3,
False,
),
# no throttling setup
(
{
"action": "delete",
"throttling_scopes": {"retrieve": {"anonymous": "test"}},
},
{},
1000,
True,
),
],
)
def test_throttle_anonymous(
view_args,
throttling_rates,
previous_requests,
expected,
api_request,
mocker,
settings,
):
settings.THROTTLING_RATES = throttling_rates
settings.THROTTLING_SCOPES = {}
ip = "92.92.92.92"
ident = {"type": "anonymous", "id": ip}
request = api_request.get("/", HTTP_X_FORWARDED_FOR=ip)
view = mocker.Mock(**view_args)
cache_key = throttling.get_cache_key("test", ident)
throttle = throttling.FunkwhaleThrottle()
history = [time.time() for _ in range(previous_requests)]
throttle.cache.set(cache_key, history)
assert throttle.allow_request(request, view) is expected
@pytest.mark.parametrize(
"view_args, throttling_rates, previous_requests, expected",
[
# room for one more requests
(
{
"action": "retrieve",
"throttling_scopes": {"retrieve": {"authenticated": "test"}},
},
{"test": {"rate": "3/s"}},
2,
True,
),
# number of requests exceeded
(
{
"action": "retrieve",
"throttling_scopes": {"retrieve": {"authenticated": "test"}},
},
{"test": {"rate": "3/s"}},
3,
False,
),
# no throttling setup
(
{
"action": "delete",
"throttling_scopes": {"retrieve": {"authenticated": "test"}},
},
{},
1000,
True,
),
],
)
def test_throttle_authenticated(
view_args,
throttling_rates,
previous_requests,
expected,
api_request,
mocker,
settings,
factories,
):
settings.THROTTLING_RATES = throttling_rates
settings.THROTTLING_SCOPES = {}
user = factories["users.User"]()
ident = {"type": "authenticated", "id": user.pk}
request = api_request.get("/")
setattr(request, "user", user)
view = mocker.Mock(**view_args)
cache_key = throttling.get_cache_key("test", ident)
throttle = throttling.FunkwhaleThrottle()
history = [time.time() for _ in range(previous_requests)]
throttle.cache.set(cache_key, history)
assert throttle.allow_request(request, view) is expected
def throttle_successive(settings, mocker, api_request):
settings.THROTTLING_RATES = {"test": {"rate": "3/s"}}
settings.THROTTLING_SCOPES = {}
ip = "92.92.92.92"
request = api_request.get("/", HTTP_X_FORWARDED_FOR=ip)
view = mocker.Mock(
action="retrieve", throttling_scopes={"retrieve": {"anonymous": "test"}}
)
throttle = throttling.FunkwhaleThrottle()
assert throttle.allow_request(request, view) is True
assert throttle.allow_request(request, view) is True
assert throttle.allow_request(request, view) is True
assert throttle.allow_request(request, view) is False
def test_throttle_attach_info(mocker):
throttle = throttling.FunkwhaleThrottle()
request = mocker.Mock()
setattr(throttle, "num_requests", 300)
setattr(throttle, "duration", 3600)
setattr(throttle, "scope", "hello")
setattr(throttle, "history", [])
setattr(throttle, "request", request)
expected = {
"num_requests": throttle.num_requests,
"duration": throttle.duration,
"history": throttle.history,
"wait": throttle.wait(),
"scope": throttle.scope,
}
throttle.attach_info()
assert request._throttle_status == expected
@pytest.mark.parametrize("method", ["throttle_success", "throttle_failure"])
def test_throttle_calls_attach_info(method, mocker):
throttle = throttling.FunkwhaleThrottle()
setattr(throttle, "key", "noop")
setattr(throttle, "now", "noop")
setattr(throttle, "duration", "noop")
setattr(throttle, "history", ["noop"])
mocker.patch.object(throttle, "cache")
attach_info = mocker.patch.object(throttle, "attach_info")
func = getattr(throttle, method)
func()
attach_info.assert_called_once_with()
def test_allow_request(api_request, settings, mocker):
settings.THROTTLING_RATES = {"test": {"rate": "2/s"}}
ip = "92.92.92.92"
request = api_request.get("/", HTTP_X_FORWARDED_FOR=ip)
allow_request = mocker.spy(throttling.FunkwhaleThrottle, "allow_request")
action = "test"
throttling_scopes = {"test": {"anonymous": "test", "authenticated": "test"}}
throttling.check_request(request, action)
throttling.check_request(request, action)
with pytest.raises(throttling.TooManyRequests):
throttling.check_request(request, action)
assert allow_request.call_count == 3
assert allow_request.call_args[0][1] == request
assert allow_request.call_args[0][2] == throttling.DummyView(
action=action, throttling_scopes=throttling_scopes
)
def test_allow_request_throttling_disabled(api_request, settings):
settings.THROTTLING_RATES = {"test": {"rate": "1/s"}}
settings.THROTTLING_ENABLED = False
ip = "92.92.92.92"
request = api_request.get("/", HTTP_X_FORWARDED_FOR=ip)
action = "test"
throttling.check_request(request, action)
# even exceeding request doesn't raise any exception
throttling.check_request(request, action)
def test_get_throttling_status_for_ident(settings, cache):
settings.THROTTLING_RATES = {
"test-1": {"rate": "30/d", "description": "description 1"},
"test-2": {"rate": "20/h", "description": "description 2"},
}
ident = {"type": "anonymous", "id": "92.92.92.92"}
test1_cache_key = throttling.get_cache_key("test-1", ident)
now = int(time.time())
cache.set(test1_cache_key, [now - 1, now - 2, now - 99999999])
expected = [
{
"id": "test-1",
"limit": 30,
"rate": "30/d",
"description": "description 1",
"duration": 24 * 3600,
"remaining": 28,
"reset": now + (24 * 3600) - 1,
"reset_seconds": (24 * 3600) - 1,
"available": None,
"available_seconds": None,
},
{
"id": "test-2",
"limit": 20,
"rate": "20/h",
"description": "description 2",
"duration": 3600,
"remaining": 20,
"reset": None,
"reset_seconds": None,
"available": None,
"available_seconds": None,
},
]
assert throttling.get_status(ident, now) == expected

View File

@ -4,6 +4,7 @@ from django.urls import reverse
from funkwhale_api.common import serializers
from funkwhale_api.common import signals
from funkwhale_api.common import tasks
from funkwhale_api.common import throttling
def test_can_detail_mutation(logged_in_api_client, factories):
@ -163,3 +164,20 @@ def test_cannot_approve_reject_without_perm(
assert mutation.is_approved is None
assert mutation.approved_by is None
def test_rate_limit(logged_in_api_client, now_time, settings, mocker):
expected_ident = {"type": "authenticated", "id": logged_in_api_client.user.pk}
expected = {
"ident": expected_ident,
"scopes": throttling.get_status(expected_ident, now_time),
"enabled": settings.THROTTLING_ENABLED,
}
get_status = mocker.spy(throttling, "get_status")
url = reverse("api:v1:rate-limit")
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == expected
get_status.assert_called_once_with(expected_ident, now_time)

View File

@ -5,6 +5,7 @@ import PIL
import random
import shutil
import tempfile
import time
import factory
import pytest
@ -308,6 +309,13 @@ def now(mocker):
return now
@pytest.fixture()
def now_time(mocker):
now = time.time()
mocker.patch("time.time", return_value=now)
return now
@pytest.fixture()
def avatar():
i = PIL.Image.new("RGBA", (400, 400), random.choice(["red", "blue", "yellow"]))

View File

@ -0,0 +1 @@
Enforce a configurable rate limit on the API to mitigate abuse (#261)

View File

@ -149,3 +149,15 @@ Then reload nginx with ``systemctl reload nginx``.
}
Then reload nginx with ``docker-compose restart nginx``.
Rate limiting
^^^^^^^^^^^^^
With this release, rate-limiting on the API is enabled by default, with high enough limits to ensure
regular users of the app aren't affected. Requests beyond allowed limits are answered with a 429 HTTP error.
For anonymous requests, the limit is applied to the IP adress of the client, and for authenticated requests, the limit
is applied to the corresponding user account. By default, anonymous requests get a lower limit than authenticated requests.
You can disable the rate-limiting feature by adding `THROTTLING_ENABLED=false` to your ``.env`` file and restarting the
services. If you are using the Funkwhale API in your project or app and want to know more about the limits, please consult https://docs.funkwhale.audio/swagger/.

View File

@ -33,6 +33,79 @@ info:
If you keep the default server (https://demo.funkwhale.audio), the default username and password
couple is "demo" and "demo".
Rate limiting
-------------
Depending on server configuration, pods running Funkwhale 0.20 and higher may rate-limit incoming
requests to prevent abuse and improve the stability of service. Requests that are dropped because of rate-limiting
receive a 429 HTTP response.
The limits themselves vary depending on:
- The client: anonymous requests are subject to lower limits than authenticated requests
- The operation being performed: Write and delete operations, as performed with DELETE, POST, PUT and PATCH HTTP methods are subject to lower limits
Those conditions are used to determine the scope of the request, which in turns determine the limit that is applied.
For instance, authenticated POST requests are bound to the `authenticated-create` scope, with a default limit of
1000 requests/hour, but anonymous POST requests are bound to the `anonymous-create` scope, with a lower limit of 1000 requests/day.
A full list of scopes with their corresponding description, and the current usage data for the client performing the request
is available via the `/api/v1/rate-limit` endpoint.
Additionally, we include HTTP headers on all API response to ensure API clients can understand:
- what scope was bound to a given request
- what is the corresponding limit
- how much similar requests can be sent before being limited
- and how much time they should wait if they have been limited
<table>
<caption>Rate limiting headers</caption>
<thead>
<th>Header</th>
<th>Example value</th>
<th>Description value</th>
</thead>
<tbody>
<tr>
<td><code>X-RateLimit-Limit</code></td>
<td>50</td>
<td>The number of allowed requests whithin a given period</td>
</tr>
<tr>
<td><code>X-RateLimit-Duration</code></td>
<td>3600</td>
<td>The time window, in seconds, during which those requests are accounted for.</td>
</tr>
<tr>
<td><code>X-RateLimit-Scope</code></td>
<td>login</td>
<td>The name of the scope as computed for the request</td>
</tr>
<tr>
<td><code>X-RateLimit-Remaining</code></td>
<td>42</td>
<td>How many requests can be sent with the same scope before the limit applies</td>
</tr>
<tr>
<td><code>Retry-After</code> (if <code>X-RateLimit-Remaining</code> is 0)</td>
<td>3543</td>
<td>How many seconds to wait before a retry</td>
</tr>
<tr>
<td><code>X-RateLimit-Reset</code></td>
<td>1568126089</td>
<td>A timestamp indicating when <code>X-RateLimit-Remaining</code> will return to its higher possible value</td>
</tr>
<tr>
<td><code>X-RateLimit-ResetSeconds</code></td>
<td>3599</td>
<td>How many seconds to wait before <code>X-RateLimit-Remaining</code> returns to its higher possible value</td>
</tr>
</tbody>
</table>
Resources
---------
@ -103,7 +176,7 @@ security:
tags:
- name: Auth and security
description: Login, logout and authorization endpoints
description: Login, logout, rate-limit and authorization endpoints
- name: Library and metadata
description: Information and metadata about musical and audio entities (albums, tracks, artists, etc.)
- name: Uploads and audio content
@ -117,7 +190,7 @@ paths:
/api/v1/oauth/apps/:
post:
tags:
- "auth"
- "Auth and security"
description:
Register an OAuth application
security: []
@ -247,6 +320,19 @@ paths:
schema:
$ref: "#/definitions/Me"
/api/v1/rate-limit/:
get:
summary: Retrive rate-limit information and current usage status
tags:
- "Auth and security"
responses:
200:
content:
application/json:
schema:
$ref: "#/definitions/RateLimitStatus"
/api/v1/artists/:
get:
summary: List artists
@ -1646,6 +1732,75 @@ definitions:
type: "boolean"
example: false
description: A boolean indicating if the user can manage instance settings and users
RateLimitStatus:
type: "object"
properties:
enabled:
type: "boolean"
example: true
description: A boolean indicating if rate-limiting is enabled on the server
ident:
type: "object"
description: Client-related data
properties:
type:
type: string
example: "anonymous"
enum:
- "authenticated"
- "anonymous"
id:
type: string
example: "92.143.42"
description: An address IP or user ID identifying the client
scopes:
type: "array"
items:
type: "object"
description: Rate-limit scope configuration and usage
properties:
id:
type: string
example: "password-reset"
description:
type: string
example: "Password reset request"
rate:
type: string
example: "30/day"
limit:
type: "integer"
format: "int64"
example: 30
duration:
type: "integer"
format: "int64"
example: 86400
remaining:
type: "integer"
format: "int64"
example: 28
description: How many requests can be sent with the same scope before the limit applies
reset:
type: "integer"
format: "int64"
example: 1568126189
description: A timestamp indicating when <code>remaining</code> will return to its higher possible value
reset_seconds:
type: "integer"
format: "int64"
example: 86267
description: How many seconds to wait before <code>remaining</code> returns to its higher possible value
available:
type: "integer"
format: "int64"
example: 1568126089
description: A timestamp indicating when the client can retry
available_seconds:
type: "integer"
format: "int64"
example: 54
description: How many seconds to wait before a retry
ResourceNotFound:
type: "object"

View File

@ -8,7 +8,7 @@
<div class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></div>
<ul class="list">
<li v-if="error == 'invalid_credentials'"><translate translate-context="Content/Login/Error message.List item/Call to action">Please double-check your username/password couple is correct</translate></li>
<li v-else><translate translate-context="Content/Login/Error message/List item">An unknown error occurred, this can mean the server is down or cannot be reached</translate></li>
<li v-else>{{ error }}</li>
</ul>
</div>
<div class="field">
@ -105,7 +105,7 @@ export default {
if (error.response.status === 400) {
self.error = "invalid_credentials"
} else {
self.error = "unknown_error"
self.error = error.backendErrors[0]
}
}
})

View File

@ -7,6 +7,7 @@ logger.default.debug('Environment variables:', process.env)
import jQuery from "jquery"
import Vue from 'vue'
import moment from 'moment'
import App from './App'
import router from './router'
import axios from 'axios'
@ -25,6 +26,7 @@ sync(store, router)
window.$ = window.jQuery = require('jquery')
require('./semantic.js')
require('masonry-layout')
let APP = null
let availableLanguages = (function () {
let l = {}
@ -91,6 +93,32 @@ axios.interceptors.response.use(function (response) {
error.backendErrors.push('Resource not found')
} else if (error.response.status === 403) {
error.backendErrors.push('Permission denied')
} else if (error.response.status === 429) {
let message
let rateLimitStatus = {
limit: error.response.headers['x-ratelimit-limit'],
scope: error.response.headers['x-ratelimit-scope'],
remaining: error.response.headers['x-ratelimit-remaining'],
duration: error.response.headers['x-ratelimit-duration'],
availableSeconds: error.response.headers['retry-after'],
reset: error.response.headers['x-ratelimit-reset'],
resetSeconds: error.response.headers['x-ratelimit-resetseconds'],
}
if (rateLimitStatus.availableSeconds) {
rateLimitStatus.availableSeconds = parseInt(rateLimitStatus.availableSeconds)
let tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true)
message = APP.$pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again in %{ delay }')
message = APP.$gettextInterpolate(message, {delay: tryAgain})
} else {
message = APP.$pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again later')
}
error.backendErrors.push(message)
store.commit("ui/addMessage", {
content: message,
date: new Date(),
level: 'error',
})
logger.default.error('This client is rate-limited!', rateLimitStatus)
} else if (error.response.status === 500) {
error.backendErrors.push('A server error occured')
} else if (error.response.data) {
@ -125,7 +153,10 @@ store.dispatch('instance/fetchFrontSettings').finally(() => {
render (h) {
return h('App')
},
components: { App }
components: { App },
created () {
APP = this
},
})
logger.default.info('Everything loaded!')