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/tests/unit/utils/test_paginator.py b/apps/api/plane/tests/unit/utils/test_paginator.py index b249f4d184b..2e2b6a60ec8 100644 --- a/apps/api/plane/tests/unit/utils/test_paginator.py +++ b/apps/api/plane/tests/unit/utils/test_paginator.py @@ -121,3 +121,55 @@ def test_no_group_by_is_unaffected(self): paginator_cls=_StubGroupedPaginator, ) assert response.data["grouped_by"] is None + + +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..3ff29213834 100644 --- a/apps/api/plane/utils/paginator.py +++ b/apps/api/plane/utils/paginator.py @@ -680,6 +680,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