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
7 changes: 6 additions & 1 deletion apps/api/plane/app/serializers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions apps/api/plane/tests/unit/utils/test_paginator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] == []
11 changes: 11 additions & 0 deletions apps/api/plane/utils/paginator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading