Repository: zulip/zulip

Issue:
Rate-limit by IP any publicly-accessible email-sending endpoints
#16190 added IP-based rate-limiting for un-authed requests in the `rate_limit` view decorator, which is effectively just REST endpoints. However, there are non-REST unauthenticated endpoints which also bear protecting.  Specifically, the `/accounts/find/` and `/new/` endpoints automatically send emails to user-supplied addresses, and are not currently rate-limited.

We should add an additional `authenticate_by_ip` rate limiting rule at 5/30min (matching the `authenticate_by_username` rule), and enforce that ratelimit for the above two endpoints.


Discussion:
Hello @mateuszmandera, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 10 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 4 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!

<!-- inactiveWarning -->
I think this is half-solved, with #19319 addressing one of the endpoints.  We still need to cover `/accounts/find/` as well, then we can resolve this.

Resolving patch:
diff --git a/zerver/views/registration.py b/zerver/views/registration.py
--- a/zerver/views/registration.py
+++ b/zerver/views/registration.py
@@ -711,6 +711,17 @@ def find_account(request: HttpRequest) -> HttpResponse:
         form = FindMyTeamForm(request.POST)
         if form.is_valid():
             emails = form.cleaned_data["emails"]
+            for i in range(len(emails)):
+                try:
+                    rate_limit_request_by_ip(request, domain="find_account_by_ip")
+                except RateLimited as e:
+                    assert e.secs_to_freedom is not None
+                    return render(
+                        request,
+                        "zerver/rate_limit_exceeded.html",
+                        context={"retry_after": int(e.secs_to_freedom)},
+                        status=429,
+                    )
 
             # Django doesn't support __iexact__in lookup with EmailField, so we have
             # to use Qs to get around that without needing to do multiple queries.
diff --git a/zproject/computed_settings.py b/zproject/computed_settings.py
--- a/zproject/computed_settings.py
+++ b/zproject/computed_settings.py
@@ -387,6 +387,9 @@ def get_dirs(self) -> List[PosixPath]:
     "create_realm_by_ip": [
         (1800, 5),
     ],
+    "find_account_by_ip": [
+        (3600, 10),
+    ],
     "password_reset_form_by_email": [
         (3600, 2),  # 2 reset emails per hour
         (86400, 5),  # 5 per day


Test patch:
diff --git a/zerver/tests/test_external.py b/zerver/tests/test_external.py
--- a/zerver/tests/test_external.py
+++ b/zerver/tests/test_external.py
@@ -1,5 +1,6 @@
 import time
-from typing import Callable, Optional
+from contextlib import contextmanager
+from typing import Callable, Iterator, Optional
 from unittest import mock, skipUnless
 
 import DNS
@@ -67,6 +68,17 @@ def test_notmailinglist(self) -> None:
             email_is_not_mit_mailing_list("sipbexch@mit.edu")
 
 
+@contextmanager
+def rate_limit_rule(range_seconds: int, num_requests: int, domain: str) -> Iterator[None]:
+    add_ratelimit_rule(range_seconds, num_requests, domain=domain)
+    try:
+        yield
+    finally:
+        # We need this in a finally block to ensure the test cleans up after itself
+        # even in case of failure, to avoid polluting the rules state.
+        remove_ratelimit_rule(range_seconds, num_requests, domain=domain)
+
+
 class RateLimitTests(ZulipTestCase):
     def setUp(self) -> None:
         super().setUp()
@@ -155,6 +167,8 @@ def default_assert_func(result: HttpResponse) -> None:
         for i in range(6):
             with mock.patch("time.time", return_value=(start_time + i * 0.1)):
                 result = request_func()
+            if i < 5:
+                self.assertNotEqual(result.status_code, 429)
 
         assert_func(result)
 
@@ -172,35 +186,51 @@ def test_hit_ratelimits_as_user(self) -> None:
 
         self.do_test_hit_ratelimits(lambda: self.send_api_message(user, "some stuff"))
 
+    @rate_limit_rule(1, 5, domain="api_by_ip")
     def test_hit_ratelimits_as_ip(self) -> None:
-        add_ratelimit_rule(1, 5, domain="api_by_ip")
-        try:
-            RateLimitedIPAddr("127.0.0.1").clear_history()
-            self.do_test_hit_ratelimits(self.send_unauthed_api_request)
-        finally:
-            # We need this in a finally block to ensure the test cleans up after itself
-            # even in case of failure, to avoid polluting the rules state.
-            remove_ratelimit_rule(1, 5, domain="api_by_ip")
+        RateLimitedIPAddr("127.0.0.1").clear_history()
+        self.do_test_hit_ratelimits(self.send_unauthed_api_request)
 
+    @rate_limit_rule(1, 5, domain="create_realm_by_ip")
     def test_create_realm_rate_limiting(self) -> None:
         def assert_func(result: HttpResponse) -> None:
             self.assertEqual(result.status_code, 429)
             self.assert_in_response("Rate limit exceeded.", result)
 
         with self.settings(OPEN_REALM_CREATION=True):
-            add_ratelimit_rule(1, 5, domain="create_realm_by_ip")
-            try:
-                RateLimitedIPAddr("127.0.0.1").clear_history()
-                self.do_test_hit_ratelimits(
-                    lambda: self.client_post("/new/", {"email": "new@zulip.com"}),
-                    assert_func=assert_func,
-                )
-            finally:
-                remove_ratelimit_rule(1, 5, domain="create_realm_by_ip")
+            RateLimitedIPAddr("127.0.0.1", domain="create_realm_by_ip").clear_history()
+            self.do_test_hit_ratelimits(
+                lambda: self.client_post("/new/", {"email": "new@zulip.com"}),
+                assert_func=assert_func,
+            )
+
+    def test_find_account_rate_limiting(self) -> None:
+        def assert_func(result: HttpResponse) -> None:
+            self.assertEqual(result.status_code, 429)
+            self.assert_in_response("Rate limit exceeded.", result)
+
+        with rate_limit_rule(1, 5, domain="find_account_by_ip"):
+            RateLimitedIPAddr("127.0.0.1", domain="find_account_by_ip").clear_history()
+            self.do_test_hit_ratelimits(
+                lambda: self.client_post("/accounts/find/", {"emails": "new@zulip.com"}),
+                assert_func=assert_func,
+            )
+
+        # Now test whether submitting multiple emails is handled correctly.
+        # The limit is set to 10 per second, so 5 requests with 2 emails
+        # submitted in each should be allowed.
+        with rate_limit_rule(1, 10, domain="find_account_by_ip"):
+            RateLimitedIPAddr("127.0.0.1", domain="find_account_by_ip").clear_history()
+            self.do_test_hit_ratelimits(
+                lambda: self.client_post(
+                    "/accounts/find/", {"emails": "new@zulip.com,new2@zulip.com"}
+                ),
+                assert_func=assert_func,
+            )
 
     @skipUnless(settings.ZILENCER_ENABLED, "requires zilencer")
+    @rate_limit_rule(1, 5, domain="api_by_remote_server")
     def test_hit_ratelimits_as_remote_server(self) -> None:
-        add_ratelimit_rule(1, 5, domain="api_by_remote_server")
         server_uuid = "1234-abcd"
         server = RemoteZulipServer(
             uuid=server_uuid,
@@ -228,7 +258,6 @@ def test_hit_ratelimits_as_remote_server(self) -> None:
             )
         finally:
             self.DEFAULT_SUBDOMAIN = original_default_subdomain
-            remove_ratelimit_rule(1, 5, domain="api_by_remote_server")
 
     def test_hit_ratelimiterlockingexception(self) -> None:
         user = self.example_user("cordelia")
diff --git a/zproject/test_extra_settings.py b/zproject/test_extra_settings.py
--- a/zproject/test_extra_settings.py
+++ b/zproject/test_extra_settings.py
@@ -265,6 +265,7 @@ def set_loglevel(logger_name: str, level: str) -> None:
     "api_by_remote_server": [],
     "authenticate_by_username": [],
     "create_realm_by_ip": [],
+    "find_account_by_ip": [],
     "password_reset_form_by_email": [],
 }