From 47ab37cadee2554eb24531f265f3dff744a75014 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Tue, 28 Jul 2026 14:49:12 +0530 Subject: [PATCH 1/4] [SECUR-236] fix: harden pagination bounds and auth brute-force rate limiting AppScan DAST remediation, ported from the plane-ee fix (#8591) and re-verified against a live plane-ce instance. - paginator: reject non-positive per_page (per_page=0 -> ZeroDivisionError -> HTTP 500) and bound the client-supplied cursor value/offset. The grouped paginators use cursor.value as the per-group page size: a negative value slices the queryset with a negative stop (ValueError -> HTTP 500) and a huge value fetches far more than max_per_page rows per group (cap bypass / DoS). One central guard in BasePaginator.paginate(); regression tests added. - auth: sign-in/sign-up (app + space) were plain Views with no rate limiting. Add the IP-based AuthenticationThrottle check plus a per-account throttle keyed on the normalized email (AuthenticationAccountThrottle). The IP key is bypassable by spoofing X-Forwarded-For (NUM_PROXIES unset); the per-account limiter caps credential guessing against a single account regardless of IP. - project serializer: mark created_by/updated_by read-only (fields="__all__" left them client-writable, allowing project ownership/attribution forgery). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/plane/app/serializers/project.py | 7 +- apps/api/plane/authentication/rate_limit.py | 27 +++++- .../plane/authentication/views/app/email.py | 35 ++++++++ .../plane/authentication/views/space/email.py | 35 ++++++++ .../plane/tests/unit/utils/test_paginator.py | 86 +++++++++++++++++++ apps/api/plane/utils/paginator.py | 18 ++++ 6 files changed, 206 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py index aef296bc6c2..13e6e7e2e40 100644 --- a/apps/api/plane/app/serializers/project.py +++ b/apps/api/plane/app/serializers/project.py @@ -34,7 +34,12 @@ class ProjectSerializer(BaseSerializer): class Meta: model = Project fields = "__all__" - read_only_fields = ["workspace", "deleted_at"] + # created_by/updated_by are audit fields set server-side by BaseModel.save() + # from the request user; with fields="__all__" they are otherwise client-writable, + # letting a caller forge project ownership/attribution. save() only backfills + # created_by when it is None, so a supplied value would survive — mark them + # read-only so the client value is ignored. + read_only_fields = ["workspace", "deleted_at", "created_by", "updated_by"] def validate_name(self, name): project_id = self.instance.id if self.instance else None diff --git a/apps/api/plane/authentication/rate_limit.py b/apps/api/plane/authentication/rate_limit.py index bfadf82b702..86982968d9e 100644 --- a/apps/api/plane/authentication/rate_limit.py +++ b/apps/api/plane/authentication/rate_limit.py @@ -6,7 +6,7 @@ import os # Third party imports -from rest_framework.throttling import AnonRateThrottle, UserRateThrottle +from rest_framework.throttling import AnonRateThrottle, SimpleRateThrottle, UserRateThrottle from rest_framework import status from rest_framework.response import Response @@ -49,6 +49,31 @@ def authentication_throttle_allows(request): return throttle.allow_request(request, None) +class AuthenticationAccountThrottle(SimpleRateThrottle): + """Per-account (submitted email) authentication throttle. + + AuthenticationThrottle keys on DRF get_ident, which honors X-Forwarded-For when + NUM_PROXIES is unset — an attacker can rotate that header to get a fresh bucket per + request and brute-force credentials unthrottled. Bucketing a second limiter by the + normalized email caps guesses against any single account regardless of source IP. + Email is normalized (strip + lower) to match the login lookup so casing tricks cannot + multiply the allowance; requests without an email fall back to the client identity. + """ + + scope = "authentication_account" + rate = os.environ.get("AUTHENTICATION_ACCOUNT_RATE_LIMIT", "5/minute") + + def get_cache_key(self, request, view=None): + email = (request.POST.get("email") or "").strip().lower() + ident = f"email:{email}" if email else f"ip:{self.get_ident(request)}" + return self.cache_format % {"scope": self.scope, "ident": ident} + + +def authentication_account_throttle_allows(request): + """Per-account counterpart to authentication_throttle_allows (see above).""" + return AuthenticationAccountThrottle().allow_request(request, None) + + class EmailVerificationThrottle(UserRateThrottle): """ Throttle for email verification code generation. diff --git a/apps/api/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py index 3d1954875c4..7b0aa00a5d4 100644 --- a/apps/api/plane/authentication/views/app/email.py +++ b/apps/api/plane/authentication/views/app/email.py @@ -10,6 +10,10 @@ # Module imports from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.rate_limit import ( + authentication_throttle_allows, + authentication_account_throttle_allows, +) from plane.authentication.utils.login import user_login from plane.license.models import Instance from plane.authentication.utils.host import base_host @@ -26,6 +30,22 @@ class SignInAuthEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") + + # Rate-limit password sign-in per IP to prevent credential brute-force. + # This is a plain django View, so DRF throttle_classes do not apply and + # the throttle must be invoked manually (as in the magic-link endpoints). + if not authentication_throttle_allows(request) or not authentication_account_throttle_allows(request): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=exc.get_error_dict(), + ) + return HttpResponseRedirect(url) + # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: @@ -135,6 +155,21 @@ def post(self, request): class SignUpAuthEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") + + # Rate-limit password sign-up per IP to prevent automated abuse, + # mirroring the sign-in path and the magic-link endpoints. + if not authentication_throttle_allows(request) or not authentication_account_throttle_allows(request): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=exc.get_error_dict(), + ) + return HttpResponseRedirect(url) + # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index 827348cef23..a2afe87f871 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -11,6 +11,10 @@ # Module imports from plane.authentication.provider.credentials.email import EmailProvider +from plane.authentication.rate_limit import ( + authentication_throttle_allows, + authentication_account_throttle_allows, +) from plane.authentication.utils.login import user_login from plane.license.models import Instance from plane.authentication.utils.host import base_host @@ -25,6 +29,22 @@ class SignInAuthSpaceEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") + + # Rate-limit password sign-in per IP to prevent credential brute-force. + # Plain django View → DRF throttle_classes do not apply, so invoke the + # throttle manually (as in the magic-link endpoints). + if not authentication_throttle_allows(request) or not authentication_account_throttle_allows(request): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=exc.get_error_dict(), + ) + return HttpResponseRedirect(url) + # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: @@ -110,6 +130,21 @@ def post(self, request): class SignUpAuthSpaceEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") + + # Rate-limit password sign-up per IP to prevent automated abuse, + # mirroring the sign-in path and the magic-link endpoints. + if not authentication_throttle_allows(request) or not authentication_account_throttle_allows(request): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=exc.get_error_dict(), + ) + return HttpResponseRedirect(url) + # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: diff --git a/apps/api/plane/tests/unit/utils/test_paginator.py b/apps/api/plane/tests/unit/utils/test_paginator.py index b249f4d184b..00ec8ccfca6 100644 --- a/apps/api/plane/tests/unit/utils/test_paginator.py +++ b/apps/api/plane/tests/unit/utils/test_paginator.py @@ -121,3 +121,89 @@ def test_no_group_by_is_unaffected(self): paginator_cls=_StubGroupedPaginator, ) assert response.data["grouped_by"] is None + + +@pytest.mark.unit +class TestGetPerPageBounds: + """get_per_page() must reject non-positive per_page before it reaches the + paginator. A per_page of 0 divides by zero in math.ceil(count / limit) and + a negative per_page slices the queryset with garbage bounds — both would + otherwise surface as an unhandled HTTP 500 (flagged by AppScan as + "Integer Overflow" on the stickies per_page parameter).""" + + @pytest.mark.parametrize("per_page", ["0", "-1", "-1000"]) + def test_non_positive_per_page_raises_parse_error(self, per_page): + request = _make_request(per_page=per_page) + with pytest.raises(ParseError): + BasePaginator().get_per_page(request) + + def test_over_max_per_page_still_rejected(self): + request = _make_request(per_page="5000") + with pytest.raises(ParseError): + BasePaginator().get_per_page(request, default_per_page=20, max_per_page=1000) + + def test_non_integer_per_page_raises_parse_error(self): + request = _make_request(per_page="abc") + with pytest.raises(ParseError): + BasePaginator().get_per_page(request) + + def test_valid_per_page_passes_through(self): + request = _make_request(per_page="30") + assert BasePaginator().get_per_page(request, default_per_page=20, max_per_page=1000) == 30 + + def test_per_page_of_one_is_allowed(self): + # The exact lower boundary must be accepted. + request = _make_request(per_page="1") + assert BasePaginator().get_per_page(request) == 1 + + +class _ExplodingPaginator: + """Fails if constructed — proves the cursor guard rejects BEFORE any paginator runs.""" + + def __init__(self, **kwargs): + raise AssertionError("paginator_cls must not be constructed for an invalid cursor") + + +@pytest.mark.unit +class TestCursorBounds: + """paginate() must reject an out-of-bounds client cursor before it drives slicing. + + The grouped paginators use cursor.value as the per-group page size + (stop = offset + (cursor.value or limit) + 1). A negative value slices the queryset + with a negative stop -> ValueError('Negative indexing is not supported') -> HTTP 500; + a huge value fetches far more than max_per_page rows per group (cap bypass / DoS). + cursor.offset must be non-negative.""" + + @pytest.mark.parametrize("cursor", ["-1:0:0", "1000000:0:0", "20:-1:0"]) + def test_out_of_bounds_cursor_rejected_before_paginator(self, cursor): + request = _make_request(cursor=cursor) + with pytest.raises(ParseError): + BasePaginator().paginate( + request=request, + queryset=None, + paginator_cls=_ExplodingPaginator, + default_per_page=20, + max_per_page=1000, + ) + + def test_valid_cursor_passes_the_guard(self): + request = _make_request(cursor="20:0:0") + response = BasePaginator().paginate( + request=request, + queryset=None, + paginator_cls=_StubGroupedPaginator, + default_per_page=20, + max_per_page=1000, + ) + assert response.data["results"] == [] + + def test_cursor_value_at_max_is_allowed(self): + request = _make_request(cursor="1000:0:0") + response = BasePaginator().paginate( + request=request, + queryset=None, + paginator_cls=_StubGroupedPaginator, + default_per_page=20, + max_per_page=1000, + ) + assert response.data["results"] == [] diff --git a/apps/api/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py index 2082041f1ac..a10a69978bc 100644 --- a/apps/api/plane/utils/paginator.py +++ b/apps/api/plane/utils/paginator.py @@ -646,6 +646,13 @@ def get_per_page(self, request, default_per_page=1000, max_per_page=1000): except ValueError: raise ParseError(detail="Invalid per_page parameter.") + # Reject non-positive values before they reach the paginator, where a + # zero limit divides by zero in math.ceil(count / limit) and a negative + # limit slices the queryset with garbage bounds — both surface as an + # unhandled HTTP 500 instead of a clean client error. + if per_page < 1: + raise ParseError(detail="Invalid per_page value. Must be at least 1.") + max_per_page = max(max_per_page, default_per_page) if per_page > max_per_page: raise ParseError(detail=f"Invalid per_page value. Cannot exceed {max_per_page}.") @@ -680,6 +687,17 @@ def paginate( except ValueError: raise ParseError(detail="Invalid cursor parameter.") + # Bound the client-supplied cursor before it drives any slicing. The grouped + # paginators use cursor.value as the per-group page size + # (stop = offset + (cursor.value or limit) + 1). Left unbounded, a negative value + # slices the queryset with a negative stop -> "Negative indexing is not supported" + # (HTTP 500), and a huge value fetches far more than max_per_page rows per group + # (max_per_page cap bypass / resource-exhaustion DoS). cursor.offset is the page + # index and must be non-negative. + effective_max_per_page = max(max_per_page, default_per_page) + if not (0 <= input_cursor.value <= effective_max_per_page) or input_cursor.offset < 0: + raise ParseError(detail="Invalid cursor parameter.") + if not paginator: if group_by_field_name: # Validate against the allowlist before the field name reaches From 3257ee9305fc14f893a55325c0c4d36615d82c11 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Tue, 28 Jul 2026 15:12:13 +0530 Subject: [PATCH 2/4] =?UTF-8?q?[SECUR-236]=20fix:=20address=20CodeRabbit?= =?UTF-8?q?=20=E2=80=94=20composite=20account=20throttle=20key=20+=20rate?= =?UTF-8?q?=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthenticationAccountThrottle keyed on (email + client IP) instead of email alone. An email-only key let anyone lock a victim out of their own account by spamming their address from other IPs (self-inflicted lockout DoS). Combining with the client IP prevents that while still capping single-source guessing. - Guard the AUTHENTICATION_ACCOUNT_RATE_LIMIT env value: a malformed rate would raise in DRF parse_rate() on every auth POST (throttle is instantiated per request), taking authentication down instance-wide. Falls back to the default. - Add regression tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/plane/authentication/rate_limit.py | 42 +++++++++++---- .../tests/unit/authentication/__init__.py | 0 .../unit/authentication/test_rate_limit.py | 54 +++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 apps/api/plane/tests/unit/authentication/__init__.py create mode 100644 apps/api/plane/tests/unit/authentication/test_rate_limit.py diff --git a/apps/api/plane/authentication/rate_limit.py b/apps/api/plane/authentication/rate_limit.py index 86982968d9e..a35a074525d 100644 --- a/apps/api/plane/authentication/rate_limit.py +++ b/apps/api/plane/authentication/rate_limit.py @@ -49,23 +49,45 @@ def authentication_throttle_allows(request): return throttle.allow_request(request, None) +def _valid_rate_or_default(value, default): + """Return `value` only if it is a well-formed DRF throttle rate ("/"), + else `default`. SimpleRateThrottle.parse_rate() raises on a malformed rate, and the + throttle is instantiated on every auth POST — an unvalidated env value would take + authentication down instance-wide. Falling back to the default keeps auth up. + """ + try: + num, period = value.split("/") + int(num) + if period[:1] not in ("s", "m", "h", "d"): + raise ValueError + except (ValueError, AttributeError): + return default + return value + + class AuthenticationAccountThrottle(SimpleRateThrottle): - """Per-account (submitted email) authentication throttle. - - AuthenticationThrottle keys on DRF get_ident, which honors X-Forwarded-For when - NUM_PROXIES is unset — an attacker can rotate that header to get a fresh bucket per - request and brute-force credentials unthrottled. Bucketing a second limiter by the - normalized email caps guesses against any single account regardless of source IP. - Email is normalized (strip + lower) to match the login lookup so casing tricks cannot - multiply the allowance; requests without an email fall back to the client identity. + """Per-(account, client-IP) authentication throttle. + + Supplements the IP-only AuthenticationThrottle by also bucketing on the normalized + submitted email, capping rapid credential guessing against a single account from a + given source. The key combines email AND client IP on purpose: keying on email alone + would let anyone lock a victim out of their own account by spamming their address from + other IPs (self-inflicted account-lockout DoS). Email is normalized (strip + lower) to + match the login lookup so casing tricks cannot multiply the allowance; requests without + an email fall back to the client identity only. + + NOTE: this does not by itself stop a spoofed-source distributed brute force — that + requires a trustworthy client IP (configure NUM_PROXIES / the proxy so X-Forwarded-For + cannot be forged). It is defense-in-depth alongside that deployment control. """ scope = "authentication_account" - rate = os.environ.get("AUTHENTICATION_ACCOUNT_RATE_LIMIT", "5/minute") + rate = _valid_rate_or_default(os.environ.get("AUTHENTICATION_ACCOUNT_RATE_LIMIT", "5/minute"), "5/minute") def get_cache_key(self, request, view=None): + ip = self.get_ident(request) email = (request.POST.get("email") or "").strip().lower() - ident = f"email:{email}" if email else f"ip:{self.get_ident(request)}" + ident = f"email:{email}|ip:{ip}" if email else f"ip:{ip}" return self.cache_format % {"scope": self.scope, "ident": ident} diff --git a/apps/api/plane/tests/unit/authentication/__init__.py b/apps/api/plane/tests/unit/authentication/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/plane/tests/unit/authentication/test_rate_limit.py b/apps/api/plane/tests/unit/authentication/test_rate_limit.py new file mode 100644 index 00000000000..7b62242146a --- /dev/null +++ b/apps/api/plane/tests/unit/authentication/test_rate_limit.py @@ -0,0 +1,54 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest +from django.test import RequestFactory + +from plane.authentication.rate_limit import ( + AuthenticationAccountThrottle, + _valid_rate_or_default, +) + + +@pytest.mark.unit +class TestValidRateOrDefault: + """A malformed AUTHENTICATION_ACCOUNT_RATE_LIMIT must not crash auth: the throttle is + built on every sign-in POST, and DRF parse_rate() raises on a bad rate. Guard falls + back to the default so authentication stays up.""" + + @pytest.mark.parametrize("bad", ["", "bad//x", "10/xyz", "abc/m", "5", "5/", None]) + def test_malformed_falls_back_to_default(self, bad): + assert _valid_rate_or_default(bad, "5/minute") == "5/minute" + + @pytest.mark.parametrize("good", ["3/m", "5/minute", "10/h", "1/s", "100/d"]) + def test_valid_rate_passes_through(self, good): + assert _valid_rate_or_default(good, "5/minute") == good + + +@pytest.mark.unit +class TestAccountThrottleCacheKey: + """The per-account throttle keys on email AND client IP. Keying on email alone would + let anyone lock a victim out of their own account by spamming their address from other + IPs; combining with the client IP prevents that self-inflicted lockout DoS.""" + + def _request(self, remote_addr, **post): + request = RequestFactory().post("/auth/sign-in/", data=post) + request.META["REMOTE_ADDR"] = remote_addr + return request + + def test_key_combines_normalized_email_and_ip(self): + key = AuthenticationAccountThrottle().get_cache_key(self._request("10.0.0.1", email="Victim@Example.COM ")) + assert "email:victim@example.com" in key # normalized (strip + lower) + assert "ip:10.0.0.1" in key + + def test_same_email_different_ip_yields_different_buckets(self): + throttle = AuthenticationAccountThrottle() + k1 = throttle.get_cache_key(self._request("1.1.1.1", email="v@example.com")) + k2 = throttle.get_cache_key(self._request("2.2.2.2", email="v@example.com")) + assert k1 != k2 # an attacker on another IP cannot consume the victim's bucket + + def test_no_email_falls_back_to_ip_only(self): + key = AuthenticationAccountThrottle().get_cache_key(self._request("9.9.9.9")) + assert "ip:9.9.9.9" in key + assert "email:" not in key From 687483ea79c4100bb85e13cd661c45ae3c955cb5 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Tue, 28 Jul 2026 15:31:40 +0530 Subject: [PATCH 3/4] chore: add copyright header to authentication test package __init__ Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/plane/tests/unit/authentication/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/plane/tests/unit/authentication/__init__.py b/apps/api/plane/tests/unit/authentication/__init__.py index e69de29bb2d..fcc34a703d7 100644 --- a/apps/api/plane/tests/unit/authentication/__init__.py +++ b/apps/api/plane/tests/unit/authentication/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. From 6bc666abdb8a79ca8428580fb71e9f59df56e893 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Tue, 28 Jul 2026 18:05:30 +0530 Subject: [PATCH 4/4] [SECUR-236] chore: drop overlapping per_page + auth fixes, defer to #9429 / #9335 Two parts of this PR fully overlapped existing open PRs against preview, so per the "drop the most recent on full overlap" call they are removed here: - per_page non-positive guard (get_per_page) -> covered identically by #9429. - password sign-in/sign-up rate limiting -> covered (more cleanly, via a decorator) by #9335 (GHSA-349j-pjw5-67q4). Reverted rate_limit.py and email.py (app + space) and removed the auth unit tests. This PR now carries only its unique, non-overlapping fixes: - grouped-paginator cursor bound in paginate() (cap-bypass DoS + negative-slice 500 that #9429 does not address), with TestCursorBounds. - Project created_by/updated_by read-only (mass-assignment). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/plane/authentication/rate_limit.py | 49 +---------------- .../plane/authentication/views/app/email.py | 35 ------------ .../plane/authentication/views/space/email.py | 35 ------------ .../tests/unit/authentication/__init__.py | 3 -- .../unit/authentication/test_rate_limit.py | 54 ------------------- .../plane/tests/unit/utils/test_paginator.py | 34 ------------ apps/api/plane/utils/paginator.py | 7 --- 7 files changed, 1 insertion(+), 216 deletions(-) delete mode 100644 apps/api/plane/tests/unit/authentication/__init__.py delete mode 100644 apps/api/plane/tests/unit/authentication/test_rate_limit.py diff --git a/apps/api/plane/authentication/rate_limit.py b/apps/api/plane/authentication/rate_limit.py index a35a074525d..bfadf82b702 100644 --- a/apps/api/plane/authentication/rate_limit.py +++ b/apps/api/plane/authentication/rate_limit.py @@ -6,7 +6,7 @@ import os # Third party imports -from rest_framework.throttling import AnonRateThrottle, SimpleRateThrottle, UserRateThrottle +from rest_framework.throttling import AnonRateThrottle, UserRateThrottle from rest_framework import status from rest_framework.response import Response @@ -49,53 +49,6 @@ def authentication_throttle_allows(request): return throttle.allow_request(request, None) -def _valid_rate_or_default(value, default): - """Return `value` only if it is a well-formed DRF throttle rate ("/"), - else `default`. SimpleRateThrottle.parse_rate() raises on a malformed rate, and the - throttle is instantiated on every auth POST — an unvalidated env value would take - authentication down instance-wide. Falling back to the default keeps auth up. - """ - try: - num, period = value.split("/") - int(num) - if period[:1] not in ("s", "m", "h", "d"): - raise ValueError - except (ValueError, AttributeError): - return default - return value - - -class AuthenticationAccountThrottle(SimpleRateThrottle): - """Per-(account, client-IP) authentication throttle. - - Supplements the IP-only AuthenticationThrottle by also bucketing on the normalized - submitted email, capping rapid credential guessing against a single account from a - given source. The key combines email AND client IP on purpose: keying on email alone - would let anyone lock a victim out of their own account by spamming their address from - other IPs (self-inflicted account-lockout DoS). Email is normalized (strip + lower) to - match the login lookup so casing tricks cannot multiply the allowance; requests without - an email fall back to the client identity only. - - NOTE: this does not by itself stop a spoofed-source distributed brute force — that - requires a trustworthy client IP (configure NUM_PROXIES / the proxy so X-Forwarded-For - cannot be forged). It is defense-in-depth alongside that deployment control. - """ - - scope = "authentication_account" - rate = _valid_rate_or_default(os.environ.get("AUTHENTICATION_ACCOUNT_RATE_LIMIT", "5/minute"), "5/minute") - - def get_cache_key(self, request, view=None): - ip = self.get_ident(request) - email = (request.POST.get("email") or "").strip().lower() - ident = f"email:{email}|ip:{ip}" if email else f"ip:{ip}" - return self.cache_format % {"scope": self.scope, "ident": ident} - - -def authentication_account_throttle_allows(request): - """Per-account counterpart to authentication_throttle_allows (see above).""" - return AuthenticationAccountThrottle().allow_request(request, None) - - class EmailVerificationThrottle(UserRateThrottle): """ Throttle for email verification code generation. diff --git a/apps/api/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py index 7b0aa00a5d4..3d1954875c4 100644 --- a/apps/api/plane/authentication/views/app/email.py +++ b/apps/api/plane/authentication/views/app/email.py @@ -10,10 +10,6 @@ # Module imports from plane.authentication.provider.credentials.email import EmailProvider -from plane.authentication.rate_limit import ( - authentication_throttle_allows, - authentication_account_throttle_allows, -) from plane.authentication.utils.login import user_login from plane.license.models import Instance from plane.authentication.utils.host import base_host @@ -30,22 +26,6 @@ class SignInAuthEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") - - # Rate-limit password sign-in per IP to prevent credential brute-force. - # This is a plain django View, so DRF throttle_classes do not apply and - # the throttle must be invoked manually (as in the magic-link endpoints). - if not authentication_throttle_allows(request) or not authentication_account_throttle_allows(request): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], - error_message="RATE_LIMIT_EXCEEDED", - ) - url = get_safe_redirect_url( - base_url=base_host(request=request, is_app=True), - next_path=next_path, - params=exc.get_error_dict(), - ) - return HttpResponseRedirect(url) - # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: @@ -155,21 +135,6 @@ def post(self, request): class SignUpAuthEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") - - # Rate-limit password sign-up per IP to prevent automated abuse, - # mirroring the sign-in path and the magic-link endpoints. - if not authentication_throttle_allows(request) or not authentication_account_throttle_allows(request): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], - error_message="RATE_LIMIT_EXCEEDED", - ) - url = get_safe_redirect_url( - base_url=base_host(request=request, is_app=True), - next_path=next_path, - params=exc.get_error_dict(), - ) - return HttpResponseRedirect(url) - # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index a2afe87f871..827348cef23 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -11,10 +11,6 @@ # Module imports from plane.authentication.provider.credentials.email import EmailProvider -from plane.authentication.rate_limit import ( - authentication_throttle_allows, - authentication_account_throttle_allows, -) from plane.authentication.utils.login import user_login from plane.license.models import Instance from plane.authentication.utils.host import base_host @@ -29,22 +25,6 @@ class SignInAuthSpaceEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") - - # Rate-limit password sign-in per IP to prevent credential brute-force. - # Plain django View → DRF throttle_classes do not apply, so invoke the - # throttle manually (as in the magic-link endpoints). - if not authentication_throttle_allows(request) or not authentication_account_throttle_allows(request): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], - error_message="RATE_LIMIT_EXCEEDED", - ) - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path, - params=exc.get_error_dict(), - ) - return HttpResponseRedirect(url) - # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: @@ -130,21 +110,6 @@ def post(self, request): class SignUpAuthSpaceEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") - - # Rate-limit password sign-up per IP to prevent automated abuse, - # mirroring the sign-in path and the magic-link endpoints. - if not authentication_throttle_allows(request) or not authentication_account_throttle_allows(request): - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], - error_message="RATE_LIMIT_EXCEEDED", - ) - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path, - params=exc.get_error_dict(), - ) - return HttpResponseRedirect(url) - # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: diff --git a/apps/api/plane/tests/unit/authentication/__init__.py b/apps/api/plane/tests/unit/authentication/__init__.py deleted file mode 100644 index fcc34a703d7..00000000000 --- a/apps/api/plane/tests/unit/authentication/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2023-present Plane Software, Inc. and contributors -# SPDX-License-Identifier: AGPL-3.0-only -# See the LICENSE file for details. diff --git a/apps/api/plane/tests/unit/authentication/test_rate_limit.py b/apps/api/plane/tests/unit/authentication/test_rate_limit.py deleted file mode 100644 index 7b62242146a..00000000000 --- a/apps/api/plane/tests/unit/authentication/test_rate_limit.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) 2023-present Plane Software, Inc. and contributors -# SPDX-License-Identifier: AGPL-3.0-only -# See the LICENSE file for details. - -import pytest -from django.test import RequestFactory - -from plane.authentication.rate_limit import ( - AuthenticationAccountThrottle, - _valid_rate_or_default, -) - - -@pytest.mark.unit -class TestValidRateOrDefault: - """A malformed AUTHENTICATION_ACCOUNT_RATE_LIMIT must not crash auth: the throttle is - built on every sign-in POST, and DRF parse_rate() raises on a bad rate. Guard falls - back to the default so authentication stays up.""" - - @pytest.mark.parametrize("bad", ["", "bad//x", "10/xyz", "abc/m", "5", "5/", None]) - def test_malformed_falls_back_to_default(self, bad): - assert _valid_rate_or_default(bad, "5/minute") == "5/minute" - - @pytest.mark.parametrize("good", ["3/m", "5/minute", "10/h", "1/s", "100/d"]) - def test_valid_rate_passes_through(self, good): - assert _valid_rate_or_default(good, "5/minute") == good - - -@pytest.mark.unit -class TestAccountThrottleCacheKey: - """The per-account throttle keys on email AND client IP. Keying on email alone would - let anyone lock a victim out of their own account by spamming their address from other - IPs; combining with the client IP prevents that self-inflicted lockout DoS.""" - - def _request(self, remote_addr, **post): - request = RequestFactory().post("/auth/sign-in/", data=post) - request.META["REMOTE_ADDR"] = remote_addr - return request - - def test_key_combines_normalized_email_and_ip(self): - key = AuthenticationAccountThrottle().get_cache_key(self._request("10.0.0.1", email="Victim@Example.COM ")) - assert "email:victim@example.com" in key # normalized (strip + lower) - assert "ip:10.0.0.1" in key - - def test_same_email_different_ip_yields_different_buckets(self): - throttle = AuthenticationAccountThrottle() - k1 = throttle.get_cache_key(self._request("1.1.1.1", email="v@example.com")) - k2 = throttle.get_cache_key(self._request("2.2.2.2", email="v@example.com")) - assert k1 != k2 # an attacker on another IP cannot consume the victim's bucket - - def test_no_email_falls_back_to_ip_only(self): - key = AuthenticationAccountThrottle().get_cache_key(self._request("9.9.9.9")) - assert "ip:9.9.9.9" in key - assert "email:" not in key diff --git a/apps/api/plane/tests/unit/utils/test_paginator.py b/apps/api/plane/tests/unit/utils/test_paginator.py index 00ec8ccfca6..2e2b6a60ec8 100644 --- a/apps/api/plane/tests/unit/utils/test_paginator.py +++ b/apps/api/plane/tests/unit/utils/test_paginator.py @@ -123,40 +123,6 @@ def test_no_group_by_is_unaffected(self): assert response.data["grouped_by"] is None -@pytest.mark.unit -class TestGetPerPageBounds: - """get_per_page() must reject non-positive per_page before it reaches the - paginator. A per_page of 0 divides by zero in math.ceil(count / limit) and - a negative per_page slices the queryset with garbage bounds — both would - otherwise surface as an unhandled HTTP 500 (flagged by AppScan as - "Integer Overflow" on the stickies per_page parameter).""" - - @pytest.mark.parametrize("per_page", ["0", "-1", "-1000"]) - def test_non_positive_per_page_raises_parse_error(self, per_page): - request = _make_request(per_page=per_page) - with pytest.raises(ParseError): - BasePaginator().get_per_page(request) - - def test_over_max_per_page_still_rejected(self): - request = _make_request(per_page="5000") - with pytest.raises(ParseError): - BasePaginator().get_per_page(request, default_per_page=20, max_per_page=1000) - - def test_non_integer_per_page_raises_parse_error(self): - request = _make_request(per_page="abc") - with pytest.raises(ParseError): - BasePaginator().get_per_page(request) - - def test_valid_per_page_passes_through(self): - request = _make_request(per_page="30") - assert BasePaginator().get_per_page(request, default_per_page=20, max_per_page=1000) == 30 - - def test_per_page_of_one_is_allowed(self): - # The exact lower boundary must be accepted. - request = _make_request(per_page="1") - assert BasePaginator().get_per_page(request) == 1 - - class _ExplodingPaginator: """Fails if constructed — proves the cursor guard rejects BEFORE any paginator runs.""" diff --git a/apps/api/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py index a10a69978bc..3ff29213834 100644 --- a/apps/api/plane/utils/paginator.py +++ b/apps/api/plane/utils/paginator.py @@ -646,13 +646,6 @@ def get_per_page(self, request, default_per_page=1000, max_per_page=1000): except ValueError: raise ParseError(detail="Invalid per_page parameter.") - # Reject non-positive values before they reach the paginator, where a - # zero limit divides by zero in math.ceil(count / limit) and a negative - # limit slices the queryset with garbage bounds — both surface as an - # unhandled HTTP 500 instead of a clean client error. - if per_page < 1: - raise ParseError(detail="Invalid per_page value. Must be at least 1.") - max_per_page = max(max_per_page, default_per_page) if per_page > max_per_page: raise ParseError(detail=f"Invalid per_page value. Cannot exceed {max_per_page}.")