mirror of https://github.com/zulip/zulip.git
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:
parent
00c9f36434
commit
5d7615879f
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue