Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions accounts/tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
24 changes: 24 additions & 0 deletions accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion freesound/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions templates/accounts/modal_registration.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
<div class="col-12">
<div class="text-center">
<h4 class="v-spacing-5">Join Freesound</h4>
{% if rate_limit_error %}
<ul class="errorlist"><li>{{ rate_limit_error }}</li></ul>
{% endif %}
<form id="registerModalForm" class="disable-on-submit bw-form bw-form-less-spacing" method="post" action="{% url 'accounts-registration-modal' %}?in_modal=1" data-check-username-url="{% url 'check_username' %}" novalidate>{% csrf_token %}
{{ registration_form.as_p }}
<button type="submit" class="btn-primary v-spacing-top-4">Join</button>
Expand Down
Loading