rate_limiter: Block IPv6 by /64.

Fixes #21544.

rate limits IPv6 by /64 prefx rather than individual addresses,
this makes all IPs of the same /64 belong to the same bucket.
This commit is contained in:
bedo 2024-09-11 00:04:36 +03:00
parent 00c9f36434
commit 5d7615879f
3 changed files with 59 additions and 8 deletions

View File

@ -1,6 +1,7 @@
import logging
import time
from abc import ABC, abstractmethod
from ipaddress import IPv6Address
from typing import Optional, cast
import orjson
@ -138,8 +139,9 @@ class RateLimitedUser(RateLimitedObject):
class RateLimitedIPAddr(RateLimitedObject):
def __init__(self, ip_addr: str, domain: str = "api_by_ip") -> None:
def __init__(self, ip_addr: str, domain: str = "api_by_ip", ipv6_prefix: int = 64) -> None:
self.ip_addr = ip_addr
self.ipv6_prefix = ipv6_prefix
self.domain = domain
if settings.RUNNING_INSIDE_TORNADO and domain in settings.RATE_LIMITING_DOMAINS_FOR_TORNADO:
backend: type[RateLimiterBackend] | None = TornadoInMemoryRateLimiterBackend
@ -149,8 +151,15 @@ class RateLimitedIPAddr(RateLimitedObject):
@override
def key(self) -> str:
# ipv6, we use the prefix as the key.
if ":" in self.ip_addr:
ip_addr_key = get_ipv6_prefix_in_hex(self.ip_addr, prefix=self.ipv6_prefix)
else:
ip_addr_key = self.ip_addr
# The angle brackets are important since IPv6 addresses contain :.
return f"{type(self).__name__}:<{self.ip_addr}>:{self.domain}"
return f"{type(self).__name__}:<{ip_addr_key}>:{self.domain}"
@override
def rules(self) -> list[tuple[int, int]]:
@ -603,6 +612,23 @@ def rate_limit_request_by_ip(request: HttpRequest, domain: str) -> None:
RateLimitedIPAddr(ip_addr, domain=domain).rate_limit_request(request)
def get_ipv6_prefix_in_hex(ipv6: str, prefix: int) -> str:
"""Return the hex representation of the /prefix block of ipv6,
which acts as the bucket key for this ipv6.
examples:
2001:0db8:ce1:12::8a2e:0370 /64 -> 2001:0db8:ce1:12
2001:0db8:0::8a2e:0370 /64 -> 2001:0db8:0000:0000
2001:0db8:0::8a2e:0370 /48 -> 2001:0db8:0000
"""
# Since prefix represents the number of bits:
# 1 hex char = 4 bits, then No.hex chars = No.bits / 4
return IPv6Address(ipv6).__format__("x")[: prefix // 4]
def should_rate_limit(request: HttpRequest) -> bool:
if not settings.RATE_LIMITING:
return False

View File

@ -200,18 +200,26 @@ class TornadoInMemoryRateLimiterBackendTest(RateLimiterBackendBase):
def test_used_in_tornado(self) -> None:
user_profile = self.example_user("hamlet")
ip_addr = "192.168.0.123"
ipv4_addr = "192.168.0.123"
ipv6_addr = "2002:DB8::21f:5bff:febf:ce22:1111"
with self.settings(RUNNING_INSIDE_TORNADO=True):
user_obj = RateLimitedUser(user_profile, domain="api_by_user")
ip_obj = RateLimitedIPAddr(ip_addr, domain="api_by_ip")
ipv4_obj = RateLimitedIPAddr(ipv4_addr, domain="api_by_ip")
ipv6_obj = RateLimitedIPAddr(ipv6_addr, domain="api_by_ip")
self.assertEqual(user_obj.backend, TornadoInMemoryRateLimiterBackend)
self.assertEqual(ip_obj.backend, TornadoInMemoryRateLimiterBackend)
self.assertEqual(ipv4_obj.backend, TornadoInMemoryRateLimiterBackend)
self.assertEqual(ipv6_obj.backend, TornadoInMemoryRateLimiterBackend)
with self.settings(RUNNING_INSIDE_TORNADO=True):
user_obj = RateLimitedUser(user_profile, domain="some_domain")
ip_obj = RateLimitedIPAddr(ip_addr, domain="some_domain")
ipv4_obj = RateLimitedIPAddr(ipv4_addr, domain="some_domain")
ipv6_obj = RateLimitedIPAddr(ipv6_addr, domain="some_domain")
self.assertEqual(user_obj.backend, RedisRateLimiterBackend)
self.assertEqual(ip_obj.backend, RedisRateLimiterBackend)
self.assertEqual(ipv4_obj.backend, RedisRateLimiterBackend)
self.assertEqual(ipv6_obj.backend, RedisRateLimiterBackend)
def test_block_access(self) -> None:
obj = self.create_object("test", [(2, 5)])
@ -249,6 +257,19 @@ class RateLimitedObjectsTest(ZulipTestCase):
obj = RateLimitedTestObject("test", rules=[], backend=RedisRateLimiterBackend)
self.assertEqual(obj.get_rules(), [(1, 9999)])
def test_ip_bucket_key(self) -> None:
ipv6_addr = "2001:db8:4a2b:21fe::4"
ipv4_addr = "192.168.0.123"
domain = "api_by_ip"
ipv4_bucket_key = RateLimitedIPAddr(ipv4_addr, domain=domain).key()
ipv6_64_bucket_key = RateLimitedIPAddr(ipv6_addr, domain=domain, ipv6_prefix=64).key()
ipv6_48_bucket_key = RateLimitedIPAddr(ipv6_addr, domain=domain, ipv6_prefix=48).key()
self.assertEqual(ipv4_bucket_key, f"RateLimitedIPAddr:<{ipv4_addr}>:{domain}")
self.assertEqual(ipv6_64_bucket_key, f"RateLimitedIPAddr:<20010db84a2b21fe>:{domain}")
self.assertEqual(ipv6_48_bucket_key, f"RateLimitedIPAddr:<20010db84a2b>:{domain}")
# Don't load the base class as a test: https://bugs.python.org/issue17519.
del RateLimiterBackendBase

View File

@ -259,8 +259,11 @@ DEFAULT_RATE_LIMITING_RULES = {
],
# Limits total number of unauthenticated API requests (primarily
# used by the public access option). Since these are
# unauthenticated requests, each IP address is a separate bucket.
# unauthenticated requests, each IPv4 address is a separate bucket.
# For IPv6, one bucket is used for each IPv6/64.
"api_by_ip": [
# be IPv4 or IPv6/64.
# 100 requests per minute.
(60, 100),
],
# Limits total requests to the Mobile Push Notifications Service
@ -308,6 +311,7 @@ DEFAULT_RATE_LIMITING_RULES = {
# sending of an email, restricting the number per IP address. This
# is a general anti-spam measure.
"sends_email_by_ip": [
# 5 emails per day.
(86400, 5),
],
# Limits access to uploaded files, in web-public contexts, done by