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 logging
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ipaddress import IPv6Address
from typing import Optional, cast from typing import Optional, cast
import orjson import orjson
@ -138,8 +139,9 @@ class RateLimitedUser(RateLimitedObject):
class RateLimitedIPAddr(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.ip_addr = ip_addr
self.ipv6_prefix = ipv6_prefix
self.domain = domain self.domain = domain
if settings.RUNNING_INSIDE_TORNADO and domain in settings.RATE_LIMITING_DOMAINS_FOR_TORNADO: if settings.RUNNING_INSIDE_TORNADO and domain in settings.RATE_LIMITING_DOMAINS_FOR_TORNADO:
backend: type[RateLimiterBackend] | None = TornadoInMemoryRateLimiterBackend backend: type[RateLimiterBackend] | None = TornadoInMemoryRateLimiterBackend
@ -149,8 +151,15 @@ class RateLimitedIPAddr(RateLimitedObject):
@override @override
def key(self) -> str: 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 :. # 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 @override
def rules(self) -> list[tuple[int, int]]: 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) 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: def should_rate_limit(request: HttpRequest) -> bool:
if not settings.RATE_LIMITING: if not settings.RATE_LIMITING:
return False return False

View File

@ -200,18 +200,26 @@ class TornadoInMemoryRateLimiterBackendTest(RateLimiterBackendBase):
def test_used_in_tornado(self) -> None: def test_used_in_tornado(self) -> None:
user_profile = self.example_user("hamlet") 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): with self.settings(RUNNING_INSIDE_TORNADO=True):
user_obj = RateLimitedUser(user_profile, domain="api_by_user") 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(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): with self.settings(RUNNING_INSIDE_TORNADO=True):
user_obj = RateLimitedUser(user_profile, domain="some_domain") 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(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: def test_block_access(self) -> None:
obj = self.create_object("test", [(2, 5)]) obj = self.create_object("test", [(2, 5)])
@ -249,6 +257,19 @@ class RateLimitedObjectsTest(ZulipTestCase):
obj = RateLimitedTestObject("test", rules=[], backend=RedisRateLimiterBackend) obj = RateLimitedTestObject("test", rules=[], backend=RedisRateLimiterBackend)
self.assertEqual(obj.get_rules(), [(1, 9999)]) 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. # Don't load the base class as a test: https://bugs.python.org/issue17519.
del RateLimiterBackendBase del RateLimiterBackendBase

View File

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