diff --git a/accounts/tests/test_user.py b/accounts/tests/test_user.py index e1497287a..cf05f223b 100644 --- a/accounts/tests/test_user.py +++ b/accounts/tests/test_user.py @@ -29,12 +29,14 @@ from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.models import Site from django.core import mail +from django.core.cache import cache from django.core.management import call_command from django.db import IntegrityError from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse from django.utils.http import int_to_base36 +from freezegun import freeze_time from accounts.forms import DeleteUserForm, FsPasswordResetForm, UsernameField from accounts.models import DeletedUser, OldUsername, Profile, ResetEmailRequest, SameUser, UserDeletionRequest @@ -245,6 +247,68 @@ def test_user_activation_fails(self): self.assertEqual(resp.status_code, 200) self.assertEqual(resp.context["user_does_not_exist"], True) + @mock.patch("django_recaptcha.fields.ReCaptchaField.validate") + @freeze_time("2026-01-01 12:00:00") + def test_registration_rate_limit(self, magic_mock_function): + # LocMemCache state persists across tests in the same process — explicitly + # clear so this test isn't affected by any rate-limit counter set earlier. + cache.clear() + + # The test client doesn't set X-Forwarded-For; without it, + # utils.ratelimit.get_ip_or_random_ip substitutes a fresh random per-request + # IP and the limiter never trips. Pin a stable IP so the bucket is shared + # across calls in this test. + ip_a = "10.0.0.1" + ip_b = "10.0.0.2" + + # 5 allowed POSTs from same IP within the (frozen) minute. Distinct usernames + # (>=3 chars per UsernameField min_length) and emails so each call exercises + # the success path through form.save(), not just the form-error path. + for i in range(5): + resp = self.client.post( + reverse("accounts-registration-modal"), + data={ + "username": f"usr{i}", + "password1": "passw0rd!XYZ", + "accepted_tos": "on", + "email1": f"a{i}@example.com", + "email2": f"a{i}@example.com", + }, + HTTP_X_FORWARDED_FOR=ip_a, + ) + self.assertNotIn("Too many registration attempts", resp.content.decode()) + + # 6th call from same IP within the same window is rate-limited. + resp = self.client.post( + reverse("accounts-registration-modal"), + data={ + "username": "ratelimit_test_user", + "password1": "passw0rd!XYZ", + "accepted_tos": "on", + "email1": "rl@example.com", + "email2": "rl@example.com", + }, + HTTP_X_FORWARDED_FOR=ip_a, + ) + self.assertEqual(resp.status_code, 200) + self.assertIn("Too many registration attempts", resp.content.decode()) + # No user created on rate-limited call (form is never validated/saved). + self.assertEqual(User.objects.filter(username="ratelimit_test_user").count(), 0) + + # A different IP shares no bucket with ip_a — should still be allowed. + resp = self.client.post( + reverse("accounts-registration-modal"), + data={ + "username": "ratelimit_test_user2", + "password1": "passw0rd!XYZ", + "accepted_tos": "on", + "email1": "rl2@example.com", + "email2": "rl2@example.com", + }, + HTTP_X_FORWARDED_FOR=ip_b, + ) + self.assertNotIn("Too many registration attempts", resp.content.decode()) + class UserDelete(TestCase): fixtures = ["licenses", "sounds"] diff --git a/accounts/views.py b/accounts/views.py index 12c495bb9..0da4eb205 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -62,6 +62,7 @@ from django.utils import timezone from django.utils.http import base36_to_int, int_to_base36 from django.views.decorators.cache import never_cache +from django_ratelimit.decorators import ratelimit from oauth2_provider.models import AccessToken import utils.sound_upload @@ -110,6 +111,7 @@ remove_uploaded_file_from_mirror_locations, ) from utils.pagination import paginate +from utils.ratelimit import key_for_ratelimiting, rate_per_ip from utils.username import ( get_parameter_user_or_404, get_user_by_username, @@ -326,8 +328,30 @@ def update_old_cc_licenses(request): return HttpResponseRedirect(reverse("accounts-home")) +@ratelimit( + key=key_for_ratelimiting, + rate=rate_per_ip, + group=settings.RATELIMIT_REGISTRATION_GROUP, + method="POST", + block=False, +) def registration_modal(request): if request.method == "POST": + if getattr(request, "limited", False): + volatile_logger.info(f"Registration rate limit triggered ({json.dumps({'ip': get_client_ip(request)})})") + # Return the modal HTML with an error banner. We deliberately do NOT + # instantiate a bound RegistrationForm here, because that would trigger + # ReCaptchaField validation (the network call we want to avoid for + # rate-limited requests). Status 200 because the modal frontend in + # static/bw-frontend/src/components/modal.js only re-renders for 2xx. + return render( + request, + "accounts/modal_registration.html", + { + "registration_form": RegistrationForm(), + "rate_limit_error": "Too many registration attempts. Please wait a moment and try again.", + }, + ) form = RegistrationForm(request.POST) if form.is_valid(): try: diff --git a/freesound/settings.py b/freesound/settings.py index b26eef83a..06dee59cc 100644 --- a/freesound/settings.py +++ b/freesound/settings.py @@ -1265,6 +1265,10 @@ SILENCED_SYSTEM_CHECKS += ["django_recaptcha.recaptcha_test_key_error"] +# Cap the server-to-server siteverify call (default is 10s) so a slow / unreachable +# Google can't tie up a worker for 10 seconds per submission. +RECAPTCHA_VERIFY_REQUEST_TIMEOUT = 3 + # ------------------------------------------------------------------------------- # Akismet @@ -1325,8 +1329,13 @@ RATELIMIT_VIEW = "accounts.views.ratelimited_error" RATELIMIT_SEARCH_GROUP = "search" RATELIMIT_SIMILARITY_GROUP = "similarity" +RATELIMIT_REGISTRATION_GROUP = "registration" RATELIMIT_DEFAULT_GROUP_RATELIMIT = "2/s" -RATELIMITS = {RATELIMIT_SEARCH_GROUP: "2/s", RATELIMIT_SIMILARITY_GROUP: "2/s"} +RATELIMITS = { + RATELIMIT_SEARCH_GROUP: "2/s", + RATELIMIT_SIMILARITY_GROUP: "2/s", + RATELIMIT_REGISTRATION_GROUP: "5/m", +} BLOCKED_IPS = [] CACHED_BLOCKED_IPS_KEY = "cached_blocked_ips" CACHED_BLOCKED_IPS_TIME = 60 * 5 # 5 minutes diff --git a/templates/accounts/modal_registration.html b/templates/accounts/modal_registration.html index 03a876647..5e52eff16 100644 --- a/templates/accounts/modal_registration.html +++ b/templates/accounts/modal_registration.html @@ -7,6 +7,9 @@

Join Freesound

+ {% if rate_limit_error %} + + {% endif %}
{% csrf_token %} {{ registration_form.as_p }}