diff --git a/docs/admin/deployment.rst b/docs/admin/deployment.rst index 8692229133..58e0c3ad52 100644 --- a/docs/admin/deployment.rst +++ b/docs/admin/deployment.rst @@ -284,6 +284,21 @@ you create: Optional. Set your `SYSTRAN Translate API key` to use machine translation by SYSTRAN. +``THROTTLE_ENABLED`` + Optional. Enables traffic throttling based on IP address (default: ``False``). + +``THROTTLE_MAX_COUNT`` + Optional. Maximum number of requests allowed in ``THROTTLE_OBSERVATION_PERIOD`` + (default: ``300``). + +``THROTTLE_OBSERVATION_PERIOD`` + Optional. A period (in seconds) in which ``THROTTLE_MAX_COUNT`` requests are + allowed. (default: ``60``). If longer than ``THROTTLE_BLOCK_DURATION``, + ``THROTTLE_BLOCK_DURATION`` will be used. + +``THROTTLE_BLOCK_DURATION`` + Optional. A duration (in seconds) for which IPs are blocked (default: ``600``). + ``TZ`` Timezone for the dynos that will run the app. Pontoon operates in UTC, so set this to ``UTC``. diff --git a/docs/admin/maintenance.rst b/docs/admin/maintenance.rst index c7986a93d6..044bf9d76a 100644 --- a/docs/admin/maintenance.rst +++ b/docs/admin/maintenance.rst @@ -47,8 +47,12 @@ In a distributed denial-of-service attack (`DDoS`_ attack), the incoming traffic flooding the victim originates from many different sources. This stops everyone else from accessing the website as there is too much traffic flowing to it. -One way to mitigate DDoS attacks is to identify the IP addresses of the -attackers (see the handy `IP detection script`_ to help with that) and block them. +One way to mitigate DDoS attacks is to enable traffic throttling. Set the +`THROTTLE_ENABLED` environment variable to True and configure other THROTTLE* +variables to limit the number of requests per period from a single IP address. + +A more involved but also more controlled approach is to identify the IP addresses of +the attackers (see the handy `IP detection script`_ to help with that) and block them. Find the attacking IP addresses in the Log Management Add-On (Papertrail) and add them to the BLOCKED_IPs config variable in Heroku Settings. diff --git a/pontoon/base/middleware.py b/pontoon/base/middleware.py index cd000cbde0..1b966d01f9 100644 --- a/pontoon/base/middleware.py +++ b/pontoon/base/middleware.py @@ -1,17 +1,22 @@ import logging +import time from ipaddress import ip_address from raygun4py.middleware.django import Provider from django.conf import settings +from django.core.cache import cache from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponseForbidden -from django.shortcuts import redirect +from django.shortcuts import redirect, render from django.urls import reverse from django.utils.deprecation import MiddlewareMixin -from pontoon.base.utils import is_ajax +from pontoon.base.utils import get_ip, is_ajax + + +log = logging.getLogger(__name__) class RaygunExceptionMiddleware(Provider, MiddlewareMixin): @@ -28,15 +33,7 @@ def process_exception(self, request, exception): class BlockedIpMiddleware(MiddlewareMixin): def process_request(self, request): - try: - ip = request.META["HTTP_X_FORWARDED_FOR"] - # If comma-separated list of IPs, take just the last one - # http://stackoverflow.com/a/18517550 - ip = ip.split(",")[-1] - except KeyError: - ip = request.META["REMOTE_ADDR"] - - ip = ip.strip() + ip = get_ip(request) # Block client IP addresses via settings variable BLOCKED_IPS if ip in settings.BLOCKED_IPS: @@ -47,7 +44,6 @@ def process_request(self, request): try: ip_obj = ip_address(ip) except ValueError: - log = logging.getLogger(__name__) log.error(f"Invalid IP detected in BlockedIpMiddleware: {ip}") return None @@ -83,3 +79,60 @@ def __call__(self, request): request.session["next_path"] = request.get_full_path() return redirect(email_consent_url) + + +class ThrottleIpMiddleware: + def __init__(self, get_response): + self.get_response = get_response + self.max_count = settings.THROTTLE_MAX_COUNT + self.observation_period = settings.THROTTLE_OBSERVATION_PERIOD + self.block_duration = settings.THROTTLE_BLOCK_DURATION + + # Set to block_duration if longer, otherwise requests will be blocked until + # the observation_period expires rather than when the block_duration expires + if self.observation_period > self.block_duration: + self.observation_period = self.block_duration + + def _throttle(self, request): + response = render(request, "429.html", status=429) + response["Retry-After"] = self.block_duration + return response + + def __call__(self, request): + if settings.THROTTLE_ENABLED is False: + return self.get_response(request) + + ip = get_ip(request) + + # Generate cache keys + observed_key = f"observed_ip_{ip}" + blocked_key = f"blocked_ip_{ip}" + + # Check if IP is currently blocked + if cache.get(blocked_key): + return self._throttle(request) + + # Fetch current request count and timestamp + request_data = cache.get(observed_key) + now = time.time() + + if request_data: + request_count, first_request_time = request_data + if request_count >= self.max_count: + # Block further requests for block_duration seconds + cache.set(blocked_key, True, self.block_duration) + log.error(f"Blocked IP {ip} for {self.block_duration} seconds") + return self._throttle(request) + else: + # Increment the request count and update cache + cache.set( + observed_key, + (request_count + 1, first_request_time), + self.observation_period, + ) + else: + # Reset the count and timestamp if first request in the period + cache.set(observed_key, (1, now), self.observation_period) + + response = self.get_response(request) + return response diff --git a/pontoon/base/templates/429.html b/pontoon/base/templates/429.html new file mode 100644 index 0000000000..813a0228e7 --- /dev/null +++ b/pontoon/base/templates/429.html @@ -0,0 +1,4 @@ +{% extends "404.html" %} + +{% block title %}Too Many Requests{% endblock %} +{% block description %}You've sent too many requests to us. Please try again later.{% endblock %} diff --git a/pontoon/base/tests/test_middleware.py b/pontoon/base/tests/test_middleware.py index 21563f2121..c40768484e 100644 --- a/pontoon/base/tests/test_middleware.py +++ b/pontoon/base/tests/test_middleware.py @@ -1,3 +1,5 @@ +import time + import pytest from django.urls import reverse @@ -29,3 +31,39 @@ def test_EmailConsentMiddleware(client, member, settings): profile.save() response = member.client.get("/") assert response.status_code == 200 + + +@pytest.mark.django_db +def test_throttle(client, settings): + """Test that requests are throttled after the limit is reached.""" + settings.THROTTLE_ENABLED = True + settings.THROTTLE_MAX_COUNT = 5 + settings.THROTTLE_BLOCK_DURATION = 2 + + url = reverse("pontoon.homepage") + ip_address = "192.168.0.1" + ip_address_2 = "192.168.0.2" + + # Make 5 requests within the limit + for _ in range(5): + response = client.get(url, REMOTE_ADDR=ip_address) + assert response.status_code == 200 + + # 6th request should be throttled + response = client.get(url, REMOTE_ADDR=ip_address) + assert response.status_code == 429 + + # Check that the IP remains blocked for the block duration + response = client.get(url, REMOTE_ADDR=ip_address) + assert response.status_code == 429 + + # Requests from another IP should not be throttled + response = client.get(url, REMOTE_ADDR=ip_address_2) + assert response.status_code == 200 + + # Wait for block duration to pass + time.sleep(settings.THROTTLE_BLOCK_DURATION) + + # Make another request after block duration + response = client.get(url, REMOTE_ADDR=ip_address) + assert response.status_code == 200 diff --git a/pontoon/base/utils.py b/pontoon/base/utils.py index e2a5f86af2..86ca7ebd3b 100644 --- a/pontoon/base/utils.py +++ b/pontoon/base/utils.py @@ -36,6 +36,18 @@ def split_ints(s): return [int(part) for part in (s or "").split(",") if part] +def get_ip(request): + try: + ip = request.META["HTTP_X_FORWARDED_FOR"] + # If comma-separated list of IPs, take just the last one + # http://stackoverflow.com/a/18517550 + ip = ip.split(",")[-1] + except KeyError: + ip = request.META["REMOTE_ADDR"] + + return ip.strip() + + def get_project_locale_from_request(request, locales): """Get Pontoon locale from Accept-language request header.""" diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index 8c294504ca..310bac7a5f 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -301,6 +301,19 @@ def _default_from_email(): log = logging.getLogger(__name__) log.error(f"Invalid IP or IP range defined in BLOCKED_IPS: {ip}") +# Enable traffic throttling based on IP address +THROTTLE_ENABLED = os.environ.get("THROTTLE_ENABLED", "False") != "False" + +# Maximum number of requests allowed in THROTTLE_OBSERVATION_PERIOD +THROTTLE_MAX_COUNT = int(os.environ.get("THROTTLE_MAX_COUNT", "300")) + +# A period (in seconds) in which THROTTLE_MAX_COUNT requests are allowed. +# If longer than THROTTLE_BLOCK_DURATION, THROTTLE_BLOCK_DURATION will be used. +THROTTLE_OBSERVATION_PERIOD = int(os.environ.get("THROTTLE_OBSERVATION_PERIOD", "60")) + +# A duration (in seconds) for which IPs are blocked +THROTTLE_BLOCK_DURATION = int(os.environ.get("THROTTLE_BLOCK_DURATION", "600")) + MIDDLEWARE = ( "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", @@ -311,6 +324,7 @@ def _default_from_email(): "django.middleware.common.CommonMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "pontoon.base.middleware.ThrottleIpMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", diff --git a/pontoon/urls.py b/pontoon/urls.py index 8cf369f242..947f71d806 100644 --- a/pontoon/urls.py +++ b/pontoon/urls.py @@ -15,6 +15,7 @@ class LocaleConverter(StringConverter): permission_denied_view = TemplateView.as_view(template_name="403.html") page_not_found_view = TemplateView.as_view(template_name="404.html") +too_many_requests_view = TemplateView.as_view(template_name="429.html") server_error_view = TemplateView.as_view(template_name="500.html") urlpatterns = [ @@ -31,6 +32,7 @@ class LocaleConverter(StringConverter): # Error pages path("403/", permission_denied_view), path("404/", page_not_found_view), + path("429/", too_many_requests_view), path("500/", server_error_view), # Robots.txt path(