diff --git a/templates/zerver/rate_limit_exceeded.html b/templates/zerver/rate_limit_exceeded.html new file mode 100644 index 0000000000..8288e0e3f3 --- /dev/null +++ b/templates/zerver/rate_limit_exceeded.html @@ -0,0 +1,23 @@ +{% extends "zerver/portico.html" %} + +{% block portico_content %} + +
+
+
+ +
+
+

{{ _("Rate limit exceeded.") }}

+

+ {% trans %}You have exceeded the limit for how + often a user can perform this action.{% endtrans %} + {% trans %}You can try again in {{retry_after}} seconds.{% endtrans %} +

+
+
+
+
+
+ +{% endblock %} diff --git a/zerver/tests/test_external.py b/zerver/tests/test_external.py index 8c957d764f..273d76994b 100644 --- a/zerver/tests/test_external.py +++ b/zerver/tests/test_external.py @@ -1,5 +1,5 @@ import time -from typing import Callable +from typing import Callable, Optional from unittest import mock, skipUnless import DNS @@ -134,19 +134,29 @@ class RateLimitTests(ZulipTestCase): newlimit = int(result["X-RateLimit-Remaining"]) self.assertEqual(limit, newlimit + 1) - def do_test_hit_ratelimits(self, request_func: Callable[[], HttpResponse]) -> HttpResponse: + def do_test_hit_ratelimits( + self, + request_func: Callable[[], HttpResponse], + assert_func: Optional[Callable[[HttpResponse], None]] = None, + ) -> HttpResponse: + def default_assert_func(result: HttpResponse) -> None: + self.assertEqual(result.status_code, 429) + json = result.json() + self.assertEqual(json.get("result"), "error") + self.assertIn("API usage exceeded rate limit", json.get("msg")) + self.assertEqual(json.get("retry-after"), 0.5) + self.assertTrue("Retry-After" in result) + self.assertEqual(result["Retry-After"], "0.5") + + if assert_func is None: + assert_func = default_assert_func + start_time = time.time() for i in range(6): with mock.patch("time.time", return_value=(start_time + i * 0.1)): result = request_func() - self.assertEqual(result.status_code, 429) - json = result.json() - self.assertEqual(json.get("result"), "error") - self.assertIn("API usage exceeded rate limit", json.get("msg")) - self.assertEqual(json.get("retry-after"), 0.5) - self.assertTrue("Retry-After" in result) - self.assertEqual(result["Retry-After"], "0.5") + assert_func(result) # We simulate waiting a second here, rather than force-clearing our history, # to make sure the rate-limiting code automatically forgives a user @@ -173,12 +183,17 @@ class RateLimitTests(ZulipTestCase): remove_ratelimit_rule(1, 5, domain="api_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"}) + lambda: self.client_post("/new/", {"email": "new@zulip.com"}), + assert_func=assert_func, ) finally: remove_ratelimit_rule(1, 5, domain="create_realm_by_ip") diff --git a/zerver/views/registration.py b/zerver/views/registration.py index b39b7d2777..821c35dac4 100644 --- a/zerver/views/registration.py +++ b/zerver/views/registration.py @@ -45,6 +45,7 @@ from zerver.lib.actions import ( lookup_default_stream_groups, ) from zerver.lib.email_validation import email_allowed_for_realm, validate_email_not_already_in_realm +from zerver.lib.exceptions import RateLimited from zerver.lib.onboarding import send_initial_realm_messages, setup_realm_internal_bots from zerver.lib.pysa import mark_sanitized from zerver.lib.send_email import EmailNotDeliveredException, FromAddress, send_email @@ -590,7 +591,16 @@ def create_realm(request: HttpRequest, creation_key: Optional[str] = None) -> Ht if request.method == "POST": form = RealmCreationForm(request.POST) if form.is_valid(): - rate_limit_request_by_ip(request, domain="create_realm_by_ip") + try: + rate_limit_request_by_ip(request, domain="create_realm_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, + ) email = form.cleaned_data["email"] activation_url = prepare_activation_url(email, request, realm_creation=True)