mirror of https://github.com/zulip/zulip.git
email_mirror: Add realm-based rate limiting.
Closes #2420 We add rate limiting (max X emails withing Y seconds per realm) to the email mirror. By creating RateLimitedRealmMirror class, inheriting from RateLimitedObject, and rate_limit_mirror_by_realm function, following a mechanism used by rate_limit_user, we're able to have this implementation mostly rely on the already existing, and proven over time, rate_limiter.py code. The rules are configurable in settings.py in RATE_LIMITING_MIRROR_REALM_RULES, analogically to RATE_LIMITING_RULES. Rate limit verification happens in the MirrorWorker in queue_processors.py. We don't rate limit missed message emails, as due to using one time addresses, they're not a spam threat. test_mirror_worker is adapted to the altered MirrorWorker code and a new test - test_mirror_worker_rate_limiting is added in test_queue_worker.py to provide coverage for these changes.
This commit is contained in:
parent
386813f42b
commit
1901775383
|
@ -1,4 +1,4 @@
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple, List
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
@ -17,8 +17,11 @@ from zerver.lib.email_notifications import convert_html_to_markdown
|
||||||
from zerver.lib.queue import queue_json_publish
|
from zerver.lib.queue import queue_json_publish
|
||||||
from zerver.lib.redis_utils import get_redis_client
|
from zerver.lib.redis_utils import get_redis_client
|
||||||
from zerver.lib.upload import upload_message_file
|
from zerver.lib.upload import upload_message_file
|
||||||
from zerver.lib.utils import generate_random_token
|
from zerver.lib.utils import generate_random_token, statsd
|
||||||
from zerver.lib.send_email import FromAddress
|
from zerver.lib.send_email import FromAddress
|
||||||
|
from zerver.lib.rate_limiter import RateLimitedObject, RateLimiterLockingException, \
|
||||||
|
is_ratelimited, incr_ratelimit
|
||||||
|
from zerver.lib.exceptions import RateLimited
|
||||||
from zerver.models import Stream, Recipient, \
|
from zerver.models import Stream, Recipient, \
|
||||||
get_user_profile_by_id, get_display_recipient, get_personal_recipient, \
|
get_user_profile_by_id, get_display_recipient, get_personal_recipient, \
|
||||||
Message, Realm, UserProfile, get_system_bot, get_user, get_stream_by_id_in_realm
|
Message, Realm, UserProfile, get_system_bot, get_user, get_stream_by_id_in_realm
|
||||||
|
@ -401,3 +404,33 @@ def mirror_email_message(data: Dict[str, str]) -> Dict[str, str]:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
|
# Email mirror rate limiter code:
|
||||||
|
|
||||||
|
class RateLimitedRealmMirror(RateLimitedObject):
|
||||||
|
def __init__(self, realm: Realm) -> None:
|
||||||
|
self.realm = realm
|
||||||
|
|
||||||
|
def key_fragment(self) -> str:
|
||||||
|
return "emailmirror:{}:{}".format(type(self.realm), self.realm.id)
|
||||||
|
|
||||||
|
def rules(self) -> List[Tuple[int, int]]:
|
||||||
|
return settings.RATE_LIMITING_MIRROR_REALM_RULES
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit_mirror_by_realm(recipient_realm: Realm) -> None:
|
||||||
|
# Code based on the rate_limit_user function:
|
||||||
|
entity = RateLimitedRealmMirror(recipient_realm)
|
||||||
|
ratelimited, time = is_ratelimited(entity)
|
||||||
|
|
||||||
|
if ratelimited:
|
||||||
|
statsd.incr("ratelimiter.limited.%s.%s" % (type(recipient_realm),
|
||||||
|
recipient_realm.id))
|
||||||
|
raise RateLimited()
|
||||||
|
|
||||||
|
try:
|
||||||
|
incr_ratelimit(entity)
|
||||||
|
except RateLimiterLockingException:
|
||||||
|
logger.warning("Email mirror rate limiter: Deadlock trying to "
|
||||||
|
"incr_ratelimit for realm %s" % (recipient_realm.name,))
|
||||||
|
raise RateLimited()
|
||||||
|
|
|
@ -10,6 +10,8 @@ from mock import patch, MagicMock
|
||||||
from typing import Any, Callable, Dict, List, Mapping, Tuple
|
from typing import Any, Callable, Dict, List, Mapping, Tuple
|
||||||
|
|
||||||
from zerver.lib.actions import encode_email_address
|
from zerver.lib.actions import encode_email_address
|
||||||
|
from zerver.lib.email_mirror import RateLimitedRealmMirror
|
||||||
|
from zerver.lib.rate_limiter import RateLimiterLockingException, clear_history
|
||||||
from zerver.lib.send_email import FromAddress
|
from zerver.lib.send_email import FromAddress
|
||||||
from zerver.lib.test_helpers import simulated_queue_client
|
from zerver.lib.test_helpers import simulated_queue_client
|
||||||
from zerver.lib.test_classes import ZulipTestCase
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
|
@ -235,6 +237,64 @@ class WorkerTest(ZulipTestCase):
|
||||||
|
|
||||||
self.assertEqual(mock_mirror_email.call_count, 3)
|
self.assertEqual(mock_mirror_email.call_count, 3)
|
||||||
|
|
||||||
|
@patch('zerver.worker.queue_processors.mirror_email')
|
||||||
|
@override_settings(RATE_LIMITING_MIRROR_REALM_RULES=[(10, 2)])
|
||||||
|
def test_mirror_worker_rate_limiting(self, mock_mirror_email: MagicMock) -> None:
|
||||||
|
fake_client = self.FakeClient()
|
||||||
|
realm = get_realm('zulip')
|
||||||
|
clear_history(RateLimitedRealmMirror(realm))
|
||||||
|
stream = get_stream('Denmark', realm)
|
||||||
|
stream_to_address = encode_email_address(stream)
|
||||||
|
data = [
|
||||||
|
dict(
|
||||||
|
message=u'\xf3test',
|
||||||
|
time=time.time(),
|
||||||
|
rcpt_to=stream_to_address
|
||||||
|
)
|
||||||
|
] * 5
|
||||||
|
for element in data:
|
||||||
|
fake_client.queue.append(('email_mirror', element))
|
||||||
|
|
||||||
|
with simulated_queue_client(lambda: fake_client):
|
||||||
|
start_time = time.time()
|
||||||
|
with patch('time.time', return_value=start_time):
|
||||||
|
worker = queue_processors.MirrorWorker()
|
||||||
|
worker.setup()
|
||||||
|
worker.start()
|
||||||
|
# Of the first 5 messages, only 2 should be processed
|
||||||
|
# (the rest being rate-limited):
|
||||||
|
self.assertEqual(mock_mirror_email.call_count, 2)
|
||||||
|
|
||||||
|
# If a new message is sent into the stream mirror, it will get rejected:
|
||||||
|
fake_client.queue.append(('email_mirror', data[0]))
|
||||||
|
worker.start()
|
||||||
|
self.assertEqual(mock_mirror_email.call_count, 2)
|
||||||
|
|
||||||
|
# However, missed message emails don't get rate limited:
|
||||||
|
with self.settings(EMAIL_GATEWAY_PATTERN="%s@example.com"):
|
||||||
|
address = 'mm' + ('x' * 32) + '@example.com'
|
||||||
|
event = dict(
|
||||||
|
message=u'\xf3test',
|
||||||
|
time=time.time(),
|
||||||
|
rcpt_to=address
|
||||||
|
)
|
||||||
|
fake_client.queue.append(('email_mirror', event))
|
||||||
|
worker.start()
|
||||||
|
self.assertEqual(mock_mirror_email.call_count, 3)
|
||||||
|
|
||||||
|
# After some times passes, emails get accepted again:
|
||||||
|
with patch('time.time', return_value=(start_time + 11.0)):
|
||||||
|
fake_client.queue.append(('email_mirror', data[0]))
|
||||||
|
worker.start()
|
||||||
|
self.assertEqual(mock_mirror_email.call_count, 4)
|
||||||
|
|
||||||
|
# If RateLimiterLockingException is thrown, we rate-limit the new message:
|
||||||
|
with patch('zerver.lib.email_mirror.incr_ratelimit',
|
||||||
|
side_effect=RateLimiterLockingException):
|
||||||
|
fake_client.queue.append(('email_mirror', data[0]))
|
||||||
|
worker.start()
|
||||||
|
self.assertEqual(mock_mirror_email.call_count, 4)
|
||||||
|
|
||||||
def test_email_sending_worker_retries(self) -> None:
|
def test_email_sending_worker_retries(self) -> None:
|
||||||
"""Tests the retry_send_email_failures decorator to make sure it
|
"""Tests the retry_send_email_failures decorator to make sure it
|
||||||
retries sending the email 3 times and then gives up."""
|
retries sending the email 3 times and then gives up."""
|
||||||
|
|
|
@ -33,7 +33,8 @@ from zerver.lib.url_preview import preview as url_preview
|
||||||
from zerver.lib.digest import handle_digest_email
|
from zerver.lib.digest import handle_digest_email
|
||||||
from zerver.lib.send_email import send_future_email, send_email_from_dict, \
|
from zerver.lib.send_email import send_future_email, send_email_from_dict, \
|
||||||
FromAddress, EmailNotDeliveredException, handle_send_email_format_changes
|
FromAddress, EmailNotDeliveredException, handle_send_email_format_changes
|
||||||
from zerver.lib.email_mirror import process_message as mirror_email
|
from zerver.lib.email_mirror import process_message as mirror_email, rate_limit_mirror_by_realm, \
|
||||||
|
is_missed_message_address, extract_and_validate
|
||||||
from zerver.lib.streams import access_stream_by_id
|
from zerver.lib.streams import access_stream_by_id
|
||||||
from zerver.tornado.socket import req_redis_key, respond_send_message
|
from zerver.tornado.socket import req_redis_key, respond_send_message
|
||||||
from confirmation.models import Confirmation, create_confirmation_link
|
from confirmation.models import Confirmation, create_confirmation_link
|
||||||
|
@ -44,6 +45,7 @@ from zerver.lib.outgoing_webhook import do_rest_call, get_outgoing_webhook_servi
|
||||||
from zerver.models import get_bot_services
|
from zerver.models import get_bot_services
|
||||||
from zulip_bots.lib import extract_query_without_mention
|
from zulip_bots.lib import extract_query_without_mention
|
||||||
from zerver.lib.bot_lib import EmbeddedBotHandler, get_bot_handler, EmbeddedBotQuitException
|
from zerver.lib.bot_lib import EmbeddedBotHandler, get_bot_handler, EmbeddedBotQuitException
|
||||||
|
from zerver.lib.exceptions import RateLimited
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
@ -480,8 +482,22 @@ class DigestWorker(QueueProcessingWorker): # nocoverage
|
||||||
@assign_queue('email_mirror')
|
@assign_queue('email_mirror')
|
||||||
class MirrorWorker(QueueProcessingWorker):
|
class MirrorWorker(QueueProcessingWorker):
|
||||||
def consume(self, event: Mapping[str, Any]) -> None:
|
def consume(self, event: Mapping[str, Any]) -> None:
|
||||||
|
rcpt_to = event['rcpt_to']
|
||||||
|
if not is_missed_message_address(rcpt_to):
|
||||||
|
# Missed message addresses are one-time use, so we don't need
|
||||||
|
# to worry about emails to them resulting in message spam.
|
||||||
|
recipient_realm = extract_and_validate(rcpt_to)[0].realm
|
||||||
|
try:
|
||||||
|
rate_limit_mirror_by_realm(recipient_realm)
|
||||||
|
except RateLimited:
|
||||||
|
msg = email.message_from_string(event["message"])
|
||||||
|
logger.warning("MirrorWorker: Rejecting an email from: %s "
|
||||||
|
"to realm: %s - rate limited."
|
||||||
|
% (msg['From'], recipient_realm.name))
|
||||||
|
return
|
||||||
|
|
||||||
mirror_email(email.message_from_string(event["message"]),
|
mirror_email(email.message_from_string(event["message"]),
|
||||||
rcpt_to=event["rcpt_to"], pre_checked=True)
|
rcpt_to=rcpt_to, pre_checked=True)
|
||||||
|
|
||||||
@assign_queue('test', queue_type="test")
|
@assign_queue('test', queue_type="test")
|
||||||
class TestWorker(QueueProcessingWorker):
|
class TestWorker(QueueProcessingWorker):
|
||||||
|
|
|
@ -687,6 +687,13 @@ CACHES = {
|
||||||
RATE_LIMITING_RULES = [
|
RATE_LIMITING_RULES = [
|
||||||
(60, 200), # 200 requests max every minute
|
(60, 200), # 200 requests max every minute
|
||||||
]
|
]
|
||||||
|
|
||||||
|
RATE_LIMITING_MIRROR_REALM_RULES = [
|
||||||
|
(60, 50), # 50 emails per minute
|
||||||
|
(300, 120), # 120 emails per 5 minutes
|
||||||
|
(3600, 600), # 600 emails per hour
|
||||||
|
]
|
||||||
|
|
||||||
DEBUG_RATE_LIMITING = DEBUG
|
DEBUG_RATE_LIMITING = DEBUG
|
||||||
REDIS_PASSWORD = get_secret('redis_password')
|
REDIS_PASSWORD = get_secret('redis_password')
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue