Skip to content

Commit

Permalink
Add traffic throttling middleware (mozilla#3402)
Browse files Browse the repository at this point in the history
  • Loading branch information
mathjazz authored Oct 13, 2024
1 parent 3910a99 commit 8ed6f69
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 14 deletions.
15 changes: 15 additions & 0 deletions docs/admin/deployment.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down
8 changes: 6 additions & 2 deletions docs/admin/maintenance.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
77 changes: 65 additions & 12 deletions pontoon/base/middleware.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions pontoon/base/templates/429.html
Original file line number Diff line number Diff line change
@@ -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 %}
38 changes: 38 additions & 0 deletions pontoon/base/tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import time

import pytest

from django.urls import reverse
Expand Down Expand Up @@ -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
12 changes: 12 additions & 0 deletions pontoon/base/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
14 changes: 14 additions & 0 deletions pontoon/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions pontoon/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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(
Expand Down

0 comments on commit 8ed6f69

Please sign in to comment.