From 1793978a71030d6b7749a33e223ebfb467909fb1 Mon Sep 17 00:00:00 2001 From: Fedor Date: Sun, 19 Jul 2026 23:41:04 +0300 Subject: [PATCH 1/4] Add scoped throttling for public auth endpoints --- .env.example | 6 + core/throttling.py | 8 ++ docs/api-safety-fix-plan.md | 26 ++++ .../tests/test_program_throttling.py | 61 +++++++++ partner_programs/views.py | 3 + procollab/settings.py | 17 +++ procollab/urls.py | 8 +- users/password_reset_urls.py | 26 ++++ users/tests/test_auth_throttling.py | 120 ++++++++++++++++++ users/token_views.py | 8 ++ users/urls.py | 2 +- users/views.py | 5 + 12 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 core/throttling.py create mode 100644 docs/api-safety-fix-plan.md create mode 100644 partner_programs/tests/test_program_throttling.py create mode 100644 users/password_reset_urls.py create mode 100644 users/tests/test_auth_throttling.py create mode 100644 users/token_views.py diff --git a/.env.example b/.env.example index cb0067a8..33c9ac29 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ EMAIL_PASSWORD= EMAIL_HOST= EMAIL_PORT= +DRF_THROTTLE_AUTH_USER_CREATE=5/min +DRF_THROTTLE_AUTH_RESEND_EMAIL=3/min +DRF_THROTTLE_AUTH_RESET_PASSWORD=3/min +DRF_THROTTLE_TOKEN_OBTAIN=10/min +DRF_THROTTLE_PROGRAM_REGISTER_NEW=10/min + SENTRY_DSN= DATABASE_NAME= diff --git a/core/throttling.py b/core/throttling.py new file mode 100644 index 00000000..fd491e21 --- /dev/null +++ b/core/throttling.py @@ -0,0 +1,8 @@ +from rest_framework.throttling import ScopedRateThrottle + + +class PostOnlyScopedRateThrottle(ScopedRateThrottle): + def allow_request(self, request, view): + if request.method != "POST": + return True + return super().allow_request(request, view) diff --git a/docs/api-safety-fix-plan.md b/docs/api-safety-fix-plan.md new file mode 100644 index 00000000..62a7bc9a --- /dev/null +++ b/docs/api-safety-fix-plan.md @@ -0,0 +1,26 @@ +# API Safety Fix Plan: Scoped Throttling + +Дата: 2026-07-19. + +Implemented in this PR: + +- Added `PostOnlyScopedRateThrottle`, a small DRF throttle helper that applies scoped throttling only to `POST` requests. +- Added scoped throttling to public/user-facing high-risk endpoints: + - `POST /auth/users/` + - `POST /auth/resend_email/` + - `POST /auth/reset_password/` + - `POST /api/token/` + - `POST /programs//register_new/` +- Added env-configurable rates in `REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]` without enabling global `DEFAULT_THROTTLE_CLASSES`. +- Added `.env.example` entries for the new rates. +- Added targeted tests that override each selected scope to `1/min` and assert the second request is throttled. + +Not changed in this PR: + +- No serializers, payload schemas, success response bodies, or existing business validation branches were changed. +- No production/dev GitHub Actions, deploy files, release process, nginx, or proxy configuration were changed. +- No global throttling was enabled for the rest of the API. + +Proxy/IP note: + +DRF throttles anonymous requests by client ident. The current backend has `SECURE_PROXY_SSL_HEADER`, but broader trusted proxy/IP handling must be coordinated with infrastructure before relying on IP throttles as an abuse boundary. Reverse proxy config should ensure client IP headers are overwritten by trusted infrastructure, not accepted from arbitrary clients. diff --git a/partner_programs/tests/test_program_throttling.py b/partner_programs/tests/test_program_throttling.py new file mode 100644 index 00000000..33428d4b --- /dev/null +++ b/partner_programs/tests/test_program_throttling.py @@ -0,0 +1,61 @@ +from unittest.mock import patch + +from django.conf import settings +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from partner_programs.tests.helpers import create_partner_program + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class PartnerProgramThrottleTests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.program = create_partner_program() + + @override_settings( + REST_FRAMEWORK=throttle_settings(program_register_new="1/min") + ) + @patch("partner_programs.services.registration.send_email.delay") + def test_external_program_registration_post_is_scoped_throttled( + self, + send_email_delay, + ): + first_response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "email": "external-throttle-one@example.com", + "password": "pass", + "first_name": "External", + "last_name": "User", + "birthday": "01-01-1990", + }, + format="json", + REMOTE_ADDR="203.0.113.20", + ) + second_response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "email": "external-throttle-two@example.com", + "password": "pass", + "first_name": "External", + "last_name": "User", + "birthday": "01-01-1990", + }, + format="json", + REMOTE_ADDR="203.0.113.20", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 429) + send_email_delay.assert_called_once() diff --git a/partner_programs/views.py b/partner_programs/views.py index b778865a..9f48b313 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -15,6 +15,7 @@ from core.serializers import EmptySerializer, SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like +from core.throttling import PostOnlyScopedRateThrottle from core.utils import build_xlsx_download_response from partner_programs.models import ( PartnerProgram, @@ -182,6 +183,8 @@ class PartnerProgramCreateUserAndRegister(generics.GenericAPIView): permission_classes = [AllowAny] serializer_class = PartnerProgramNewUserSerializer queryset = PartnerProgram.objects.all() + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "program_register_new" def post(self, request, *args, **kwargs): data = request.data diff --git a/procollab/settings.py b/procollab/settings.py index b1d1ca48..9ab7e693 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -163,6 +163,23 @@ "rest_framework.renderers.BrowsableAPIRenderer", "rest_framework.renderers.AdminRenderer", ], + "DEFAULT_THROTTLE_RATES": { + "auth_user_create": config( + "DRF_THROTTLE_AUTH_USER_CREATE", default="5/min", cast=str + ), + "auth_resend_email": config( + "DRF_THROTTLE_AUTH_RESEND_EMAIL", default="3/min", cast=str + ), + "auth_reset_password": config( + "DRF_THROTTLE_AUTH_RESET_PASSWORD", default="3/min", cast=str + ), + "token_obtain": config( + "DRF_THROTTLE_TOKEN_OBTAIN", default="10/min", cast=str + ), + "program_register_new": config( + "DRF_THROTTLE_PROGRAM_REGISTER_NEW", default="10/min", cast=str + ), + }, } ASGI_APPLICATION = "procollab.asgi.application" diff --git a/procollab/urls.py b/procollab/urls.py index 730d8143..c48adb8e 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -6,11 +6,11 @@ from drf_yasg.views import get_schema_view from rest_framework import authentication, permissions from rest_framework_simplejwt.views import ( - TokenObtainPairView, TokenRefreshView, TokenVerifyView, ) from users.authentication import ActivityTrackingJWTAuthentication +from users.token_views import ThrottledTokenObtainPairView schema_view = get_schema_view( openapi.Info( @@ -58,7 +58,11 @@ path("courses/", include("courses.urls", namespace="courses")), path("rate-project/", include(("project_rates.urls", "rate_projects"))), path("feed/", include("feed.urls", namespace="feed")), - path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path( + "api/token/", + ThrottledTokenObtainPairView.as_view(), + name="token_obtain_pair", + ), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), path("", include("metrics.urls", namespace="metrics")), diff --git a/users/password_reset_urls.py b/users/password_reset_urls.py new file mode 100644 index 00000000..70bf102c --- /dev/null +++ b/users/password_reset_urls.py @@ -0,0 +1,26 @@ +from django.urls import path +from django_rest_passwordreset.views import ( + ResetPasswordConfirm, + ResetPasswordRequestToken, + ResetPasswordValidateToken, +) + +from core.throttling import PostOnlyScopedRateThrottle + +app_name = "password_reset" + + +class ThrottledResetPasswordRequestToken(ResetPasswordRequestToken): + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "auth_reset_password" + + +urlpatterns = [ + path("", ThrottledResetPasswordRequestToken.as_view(), name="reset-password-request"), + path("confirm/", ResetPasswordConfirm.as_view(), name="reset-password-confirm"), + path( + "validate_token/", + ResetPasswordValidateToken.as_view(), + name="reset-password-validate", + ), +] diff --git a/users/tests/test_auth_throttling.py b/users/tests/test_auth_throttling.py new file mode 100644 index 00000000..b6b1f2e0 --- /dev/null +++ b/users/tests/test_auth_throttling.py @@ -0,0 +1,120 @@ +from unittest.mock import patch + +from django.conf import settings +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from tests.constants import USER_CREATE_DATA +from users.tests.helpers import build_user + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class AuthThrottleTests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + + @override_settings( + REST_FRAMEWORK=throttle_settings(auth_user_create="1/min") + ) + @patch("users.views.verify_email") + def test_user_registration_post_is_scoped_throttled(self, verify_email_mock): + first_response = self.client.post( + "/auth/users/", + USER_CREATE_DATA, + format="json", + REMOTE_ADDR="203.0.113.10", + ) + second_payload = { + **USER_CREATE_DATA, + "email": "second-user@example.com", + } + second_response = self.client.post( + "/auth/users/", + second_payload, + format="json", + REMOTE_ADDR="203.0.113.10", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 429) + verify_email_mock.assert_called_once() + + @override_settings( + REST_FRAMEWORK=throttle_settings(auth_resend_email="1/min") + ) + @patch("users.views.verify_email") + def test_resend_verify_email_post_is_scoped_throttled(self, verify_email_mock): + user = build_user(email="inactive-throttle@example.com", is_active=False) + + first_response = self.client.post( + "/auth/resend_email/", + {"email": user.email}, + format="json", + REMOTE_ADDR="203.0.113.11", + ) + second_response = self.client.post( + "/auth/resend_email/", + {"email": user.email}, + format="json", + REMOTE_ADDR="203.0.113.11", + ) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 429) + verify_email_mock.assert_called_once() + + @override_settings( + REST_FRAMEWORK=throttle_settings(token_obtain="1/min") + ) + def test_token_obtain_post_is_scoped_throttled(self): + user = build_user(email="token-throttle@example.com") + payload = {"email": user.email, "password": "very_strong_password"} + + first_response = self.client.post( + "/api/token/", + payload, + format="json", + REMOTE_ADDR="203.0.113.12", + ) + second_response = self.client.post( + "/api/token/", + payload, + format="json", + REMOTE_ADDR="203.0.113.12", + ) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 429) + + @override_settings( + REST_FRAMEWORK=throttle_settings(auth_reset_password="1/min") + ) + @patch("users.signals.EmailMultiAlternatives") + def test_password_reset_request_post_is_scoped_throttled(self, _email_message): + user = build_user(email="reset-throttle@example.com") + + first_response = self.client.post( + "/auth/reset_password/", + {"email": user.email}, + format="json", + REMOTE_ADDR="203.0.113.13", + ) + second_response = self.client.post( + "/auth/reset_password/", + {"email": user.email}, + format="json", + REMOTE_ADDR="203.0.113.13", + ) + + self.assertNotEqual(first_response.status_code, 429) + self.assertEqual(second_response.status_code, 429) diff --git a/users/token_views.py b/users/token_views.py new file mode 100644 index 00000000..cd9aa006 --- /dev/null +++ b/users/token_views.py @@ -0,0 +1,8 @@ +from rest_framework_simplejwt.views import TokenObtainPairView + +from core.throttling import PostOnlyScopedRateThrottle + + +class ThrottledTokenObtainPairView(TokenObtainPairView): + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "token_obtain" diff --git a/users/urls.py b/users/urls.py index b5e376c0..33477538 100644 --- a/users/urls.py +++ b/users/urls.py @@ -81,6 +81,6 @@ ), path( "reset_password/", - include("django_rest_passwordreset.urls", namespace="password_reset"), + include("users.password_reset_urls", namespace="password_reset"), ), ] diff --git a/users/views.py b/users/views.py index 74b08b54..cd1c32f9 100644 --- a/users/views.py +++ b/users/views.py @@ -32,6 +32,7 @@ from core.models import SkillToObject, Specialization, SpecializationCategory from core.pagination import Pagination from core.permissions import IsOwnerOrReadOnly +from core.throttling import PostOnlyScopedRateThrottle from events.models import Event from events.serializers import EventsListSerializer from partner_programs.models import PartnerProgram @@ -79,6 +80,8 @@ class UserList(ListCreateAPIView): pagination_class = UsersPagination filter_backends = (filters.DjangoFilterBackend,) filterset_class = UserFilter + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "auth_user_create" def get_permissions(self): if self.request.method == "POST": @@ -478,6 +481,8 @@ def put(self, request: Request, pk): class ResendVerifyEmail(GenericAPIView): permission_classes = [AllowAny] serializer_class = ResendVerifyEmailSerializer + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "auth_resend_email" def post(self, request, *args, **kwargs): try: From 0a054697914e8e92af2064c2e56ee03b0bf43163 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 20 Jul 2026 00:01:15 +0300 Subject: [PATCH 2/4] Align auth register throttle scope --- .env.example | 2 +- docs/api-safety-fix-plan.md | 1 + procollab/settings.py | 4 ++-- users/tests/test_auth_throttling.py | 2 +- users/views.py | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 33c9ac29..57c5616e 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ EMAIL_PASSWORD= EMAIL_HOST= EMAIL_PORT= -DRF_THROTTLE_AUTH_USER_CREATE=5/min +DRF_THROTTLE_AUTH_REGISTER=5/min DRF_THROTTLE_AUTH_RESEND_EMAIL=3/min DRF_THROTTLE_AUTH_RESET_PASSWORD=3/min DRF_THROTTLE_TOKEN_OBTAIN=10/min diff --git a/docs/api-safety-fix-plan.md b/docs/api-safety-fix-plan.md index 62a7bc9a..7e56d529 100644 --- a/docs/api-safety-fix-plan.md +++ b/docs/api-safety-fix-plan.md @@ -12,6 +12,7 @@ Implemented in this PR: - `POST /api/token/` - `POST /programs//register_new/` - Added env-configurable rates in `REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]` without enabling global `DEFAULT_THROTTLE_CLASSES`. +- Scoped rate keys are `auth_register`, `auth_resend_email`, `auth_reset_password`, `token_obtain`, and `program_register_new`. - Added `.env.example` entries for the new rates. - Added targeted tests that override each selected scope to `1/min` and assert the second request is throttled. diff --git a/procollab/settings.py b/procollab/settings.py index 9ab7e693..5e35c159 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -164,8 +164,8 @@ "rest_framework.renderers.AdminRenderer", ], "DEFAULT_THROTTLE_RATES": { - "auth_user_create": config( - "DRF_THROTTLE_AUTH_USER_CREATE", default="5/min", cast=str + "auth_register": config( + "DRF_THROTTLE_AUTH_REGISTER", default="5/min", cast=str ), "auth_resend_email": config( "DRF_THROTTLE_AUTH_RESEND_EMAIL", default="3/min", cast=str diff --git a/users/tests/test_auth_throttling.py b/users/tests/test_auth_throttling.py index b6b1f2e0..8606a149 100644 --- a/users/tests/test_auth_throttling.py +++ b/users/tests/test_auth_throttling.py @@ -24,7 +24,7 @@ def setUp(self): self.client = APIClient() @override_settings( - REST_FRAMEWORK=throttle_settings(auth_user_create="1/min") + REST_FRAMEWORK=throttle_settings(auth_register="1/min") ) @patch("users.views.verify_email") def test_user_registration_post_is_scoped_throttled(self, verify_email_mock): diff --git a/users/views.py b/users/views.py index cd1c32f9..abe49592 100644 --- a/users/views.py +++ b/users/views.py @@ -81,7 +81,7 @@ class UserList(ListCreateAPIView): filter_backends = (filters.DjangoFilterBackend,) filterset_class = UserFilter throttle_classes = [PostOnlyScopedRateThrottle] - throttle_scope = "auth_user_create" + throttle_scope = "auth_register" def get_permissions(self): if self.request.method == "POST": From 6867cd59cfec885de61017d4cc21da706d624189 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 20 Jul 2026 00:11:57 +0300 Subject: [PATCH 3/4] Read scoped throttle rates dynamically --- core/throttling.py | 6 ++++ users/tests/test_auth_throttling.py | 53 ++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/core/throttling.py b/core/throttling.py index fd491e21..fc20abb1 100644 --- a/core/throttling.py +++ b/core/throttling.py @@ -1,7 +1,13 @@ from rest_framework.throttling import ScopedRateThrottle +from rest_framework.settings import api_settings class PostOnlyScopedRateThrottle(ScopedRateThrottle): + def get_rate(self): + if not getattr(self, "scope", None): + return None + return api_settings.DEFAULT_THROTTLE_RATES.get(self.scope) + def allow_request(self, request, view): if request.method != "POST": return True diff --git a/users/tests/test_auth_throttling.py b/users/tests/test_auth_throttling.py index 8606a149..49bb8642 100644 --- a/users/tests/test_auth_throttling.py +++ b/users/tests/test_auth_throttling.py @@ -3,8 +3,11 @@ from django.conf import settings from django.core.cache import cache from django.test import TestCase, override_settings -from rest_framework.test import APIClient +from rest_framework.response import Response +from rest_framework.test import APIClient, APIRequestFactory +from rest_framework.views import APIView +from core.throttling import PostOnlyScopedRateThrottle from tests.constants import USER_CREATE_DATA from users.tests.helpers import build_user @@ -18,10 +21,22 @@ def throttle_settings(**rates): return rest_framework +class DummyPostOnlyThrottleView(APIView): + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "auth_register" + + def get(self, _request): + return Response({"ok": True}) + + def post(self, _request): + return Response({"ok": True}) + + class AuthThrottleTests(TestCase): def setUp(self): cache.clear() self.client = APIClient() + self.factory = APIRequestFactory() @override_settings( REST_FRAMEWORK=throttle_settings(auth_register="1/min") @@ -118,3 +133,39 @@ def test_password_reset_request_post_is_scoped_throttled(self, _email_message): self.assertNotEqual(first_response.status_code, 429) self.assertEqual(second_response.status_code, 429) + + @override_settings( + REST_FRAMEWORK=throttle_settings(auth_register="1/min") + ) + def test_post_only_throttle_allows_get_and_options(self): + view = DummyPostOnlyThrottleView.as_view() + + for method in ("get", "options"): + for _ in range(2): + response = view( + getattr(self.factory, method)( + "/unused/", + REMOTE_ADDR="203.0.113.14", + ) + ) + self.assertNotEqual(response.status_code, 429) + + first_response = view( + self.factory.post( + "/unused/", + {}, + format="json", + REMOTE_ADDR="203.0.113.14", + ) + ) + second_response = view( + self.factory.post( + "/unused/", + {}, + format="json", + REMOTE_ADDR="203.0.113.14", + ) + ) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 429) From 3388e5412b24c0f04175f13bd5cc481e0160be6c Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 20 Jul 2026 00:29:00 +0300 Subject: [PATCH 4/4] Fix post-only throttle direct test --- users/tests/test_auth_throttling.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/users/tests/test_auth_throttling.py b/users/tests/test_auth_throttling.py index 49bb8642..3dd94bf6 100644 --- a/users/tests/test_auth_throttling.py +++ b/users/tests/test_auth_throttling.py @@ -3,6 +3,7 @@ from django.conf import settings from django.core.cache import cache from django.test import TestCase, override_settings +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.test import APIClient, APIRequestFactory from rest_framework.views import APIView @@ -22,6 +23,7 @@ def throttle_settings(**rates): class DummyPostOnlyThrottleView(APIView): + permission_classes = [AllowAny] throttle_classes = [PostOnlyScopedRateThrottle] throttle_scope = "auth_register"