From c615092d1952c0e3599c0abe9175a51f58809c15 Mon Sep 17 00:00:00 2001 From: Jono Booth Date: Fri, 6 Mar 2026 15:52:38 +0200 Subject: [PATCH 1/5] fix: SAML registration requests being rate limited --- .../djangoapps/user_authn/views/register.py | 7 +- .../user_authn/views/tests/test_register.py | 73 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index aff210e7b26d..29d2d7a50fa8 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -577,7 +577,7 @@ def post(self, request): HttpResponse: 403 operation not allowed """ should_be_rate_limited = getattr(request, 'limited', False) - if should_be_rate_limited: + if should_be_rate_limited and not pipeline.running(request): return JsonResponse({'error_code': 'forbidden-request'}, status=403) if is_require_third_party_auth_enabled() and not pipeline.running(request): @@ -875,7 +875,7 @@ def country_handler(self, request): } @method_decorator( - ratelimit(key=REAL_IP_KEY, rate=settings.REGISTRATION_VALIDATION_RATELIMIT, method='POST', block=True) + ratelimit(key=REAL_IP_KEY, rate=settings.REGISTRATION_VALIDATION_RATELIMIT, method='POST', block=False) ) def post(self, request): """ @@ -897,6 +897,9 @@ def post(self, request): can get extra verification checks if entered along with others, like when the password may not equal the username. """ + if getattr(request, 'limited', False) and not pipeline.running(request): + return Response(status=403) + field_key = request.data.get('form_field_key') validation_decisions = {} diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 54d42efa55c0..d7cab8f83232 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -2068,6 +2068,52 @@ def _assert_social_auth_provider_present(self, field_settings, backend): "defaultValue": backend.name }) + @override_settings( + REGISTRATION_RATELIMIT='1/d', + CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'registration_ratelimit_tpa', + } + } + ) + def test_rate_limiting_exempted_for_saml_pipeline(self): + """ + Confirm that a registration POST with an active SAML/TPA pipeline is not + blocked by IP-based rate limiting, even after the limit is exhausted. + """ + # Exhaust the rate limit (limit is 1/d, so one request uses the allowance) + self.client.post(self.url, { + "email": "first@example.com", + "name": self.NAME, + "username": "firstuser", + "password": self.PASSWORD, + "honor_code": "true", + }) + # Without a pipeline, the next POST from the same IP is rate limited + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "honor_code": "true", + }) + assert response.status_code == 403 + assert response.json().get('error_code') == 'forbidden-request' + + # With an active SAML/TPA pipeline, the same IP is not blocked by rate limiting + with simulate_running_pipeline( + 'openedx.core.djangoapps.user_authn.views.register.pipeline', 'saml-idp' + ): + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "honor_code": "true", + }) + assert response.status_code != 403 or response.json().get('error_code') != 'forbidden-request' + @ddt.ddt class RegistrationViewTestV2(RegistrationViewTestV1): @@ -3028,6 +3074,33 @@ def test_rate_limiting_registration_view(self): response = self.request_without_auth('post', self.path) assert response.status_code == 403 + @override_settings( + REGISTRATION_VALIDATION_RATELIMIT='1/d', + CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'validation_ratelimit_tpa', + } + } + ) + def test_rate_limiting_exempted_for_saml_pipeline(self): + """ + Confirm that validation requests with an active SAML/TPA pipeline are not + blocked by IP-based rate limiting, even after the limit is exhausted. + """ + # Exhaust the rate limit (limit is 1/d, so one request uses the allowance) + self.request_without_auth('post', self.path) + # Without a pipeline, the next request from the same IP is rate limited + response = self.request_without_auth('post', self.path) + assert response.status_code == 403 + + # With an active SAML/TPA pipeline, the same IP is not blocked by rate limiting + with simulate_running_pipeline( + 'openedx.core.djangoapps.user_authn.views.register.pipeline', 'saml-idp' + ): + response = self.request_without_auth('post', self.path) + assert response.status_code != 403 + def test_single_field_validation(self): """ Test that if `is_authn_mfe` is provided in request along with form_field_key, only From 5bae7036f2f6416577495aeabe289fd2bbfa5630 Mon Sep 17 00:00:00 2001 From: Jono Booth Date: Fri, 6 Mar 2026 16:17:37 +0200 Subject: [PATCH 2/5] Update openedx/core/djangoapps/user_authn/views/tests/test_register.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- openedx/core/djangoapps/user_authn/views/tests/test_register.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index d7cab8f83232..d4c568004eca 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -2103,7 +2103,7 @@ def test_rate_limiting_exempted_for_saml_pipeline(self): # With an active SAML/TPA pipeline, the same IP is not blocked by rate limiting with simulate_running_pipeline( - 'openedx.core.djangoapps.user_authn.views.register.pipeline', 'saml-idp' + 'openedx.core.djangoapps.user_authn.views.register.pipeline', 'tpa-saml' ): response = self.client.post(self.url, { "email": self.EMAIL, From fa542454856ccd2823f99a97fcd654f2d9586cd1 Mon Sep 17 00:00:00 2001 From: Jono Booth Date: Fri, 6 Mar 2026 16:17:57 +0200 Subject: [PATCH 3/5] Update openedx/core/djangoapps/user_authn/views/tests/test_register.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../core/djangoapps/user_authn/views/tests/test_register.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index d4c568004eca..1c0f4b56a5cc 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -2112,7 +2112,8 @@ def test_rate_limiting_exempted_for_saml_pipeline(self): "password": self.PASSWORD, "honor_code": "true", }) - assert response.status_code != 403 or response.json().get('error_code') != 'forbidden-request' + self.assertHttpOK(response) + self.assertNotEqual(response.json().get('error_code'), 'forbidden-request') @ddt.ddt From c1d7e38c5150a7207e081cf8f28460241367b4e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:18:30 +0000 Subject: [PATCH 4/5] Initial plan From c634559653a75cee2024521a8be60533ab40c240 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:22:05 +0000 Subject: [PATCH 5/5] Strengthen assertion in validation rate limit SAML exemption test Co-authored-by: jono-booth <6801693+jono-booth@users.noreply.github.com> --- .../core/djangoapps/user_authn/views/tests/test_register.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 1c0f4b56a5cc..646e1afeb94d 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -3100,7 +3100,8 @@ def test_rate_limiting_exempted_for_saml_pipeline(self): 'openedx.core.djangoapps.user_authn.views.register.pipeline', 'saml-idp' ): response = self.request_without_auth('post', self.path) - assert response.status_code != 403 + self.assertHttpOK(response) + assert response.json().get('validation_decisions') == {} def test_single_field_validation(self): """