Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/api/plane/api/views/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
UserFavorite,
)
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.order_queryset import ISSUE_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
Expand Down Expand Up @@ -854,7 +855,7 @@ def get(self, request, slug, project_id, cycle_id):
Returns paginated results with work item details, assignees, and labels.
"""
# List
order_by = request.GET.get("order_by", "created_at")
order_by = sanitize_order_by(request.GET.get("order_by", "created_at"), ISSUE_ORDER_BY_ALLOWLIST, "created_at")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
.annotate(
Expand Down
9 changes: 7 additions & 2 deletions apps/api/plane/api/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
)
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
from plane.utils.order_queryset import ACTIVITY_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
Expand Down Expand Up @@ -1692,7 +1693,9 @@ def get(self, request, slug, project_id, issue_id):
)
.filter(project__archived_at__isnull=True)
.select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at"))
).order_by(
sanitize_order_by(request.GET.get("order_by", "created_at"), ACTIVITY_ORDER_BY_ALLOWLIST, "created_at")
)

return self.paginate(
request=request,
Expand Down Expand Up @@ -1749,7 +1752,9 @@ def get(self, request, slug, project_id, issue_id, pk):
.filter(project__archived_at__isnull=True)
.select_related("actor", "workspace", "issue", "project")
)
.order_by(request.GET.get("order_by", "created_at"))
.order_by(
sanitize_order_by(request.GET.get("order_by", "created_at"), ACTIVITY_ORDER_BY_ALLOWLIST, "created_at")
)
.first()
)

Expand Down
5 changes: 3 additions & 2 deletions apps/api/plane/api/views/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
from plane.utils.host import base_host
from plane.utils.order_queryset import ISSUE_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.utils.openapi import (
module_docs,
module_issue_docs,
Expand Down Expand Up @@ -597,7 +598,7 @@ def get(self, request, slug, project_id, module_id):
Retrieve all work items assigned to a module with detailed information.
Returns paginated results including assignees, labels, and attachments.
"""
order_by = request.GET.get("order_by", "created_at")
order_by = sanitize_order_by(request.GET.get("order_by", "created_at"), ISSUE_ORDER_BY_ALLOWLIST, "created_at")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id, issue_module__deleted_at__isnull=True)
.annotate(
Expand Down Expand Up @@ -803,7 +804,7 @@ def get(self, request, slug, project_id, module_id, issue_id):
Retrieve all work items assigned to a module with detailed information.
Returns paginated results including assignees, labels, and attachments.
"""
order_by = request.GET.get("order_by", "created_at")
order_by = sanitize_order_by(request.GET.get("order_by", "created_at"), ISSUE_ORDER_BY_ALLOWLIST, "created_at")
issues = (
Issue.issue_objects.filter(
issue_module__module_id=module_id,
Expand Down
9 changes: 8 additions & 1 deletion apps/api/plane/app/views/intake/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
IssueDescriptionVersionDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import INTAKE_ISSUE_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.app.views.base import BaseAPIView
Expand Down Expand Up @@ -195,7 +196,13 @@ def list(self, request, slug, project_id):
Value([], output_field=ArrayField(UUIDField())),
)
)
).order_by(request.GET.get("order_by", "-issue__created_at"))
).order_by(
sanitize_order_by(
request.GET.get("order_by", "-issue__created_at"),
INTAKE_ISSUE_ORDER_BY_ALLOWLIST,
"-issue__created_at",
)
)
# Intake status filter
intake_status = [item for item in request.GET.get("status", "-2").split(",") if item != "null"]
if intake_status:
Expand Down
7 changes: 6 additions & 1 deletion apps/api/plane/app/views/notification/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator
from plane.utils.order_queryset import NOTIFICATION_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.app.permissions import allow_permission, ROLE

# Module imports
Expand Down Expand Up @@ -139,7 +140,11 @@ def list(self, request, slug):
# Pagination
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
order_by=sanitize_order_by(
request.GET.get("order_by", "-created_at"),
NOTIFICATION_ORDER_BY_ALLOWLIST,
"-created_at",
),
request=request,
queryset=(notifications),
on_results=lambda notifications: NotificationSerializer(notifications, many=True).data,
Expand Down
7 changes: 6 additions & 1 deletion apps/api/plane/app/views/project/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
)
from plane.db.models.intake import IntakeIssueStatus
from plane.utils.host import base_host
from plane.utils.order_queryset import PROJECT_ORDER_BY_ALLOWLIST, sanitize_order_by


class ProjectViewSet(BaseViewSet):
Expand Down Expand Up @@ -128,7 +129,11 @@ def list_detail(self, request, slug):

if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
order_by=sanitize_order_by(
request.GET.get("order_by", "-created_at"),
PROJECT_ORDER_BY_ALLOWLIST,
"-created_at",
),
request=request,
queryset=(projects),
on_results=lambda projects: ProjectListSerializer(projects, many=True).data,
Expand Down
7 changes: 6 additions & 1 deletion apps/api/plane/app/views/user/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
)
from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator
from plane.utils.order_queryset import ACTIVITY_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.authentication.utils.host import user_ip
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
from plane.utils.host import base_host
Expand Down Expand Up @@ -384,7 +385,11 @@ def get(self, request):
)

return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
order_by=sanitize_order_by(
request.GET.get("order_by", "-created_at"),
ACTIVITY_ORDER_BY_ALLOWLIST,
"-created_at",
),
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data,
Expand Down
10 changes: 8 additions & 2 deletions apps/api/plane/app/views/view/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
ModuleIssue,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.order_queryset import VIEW_ORDER_BY_ALLOWLIST, order_issue_queryset, sanitize_order_by
from plane.bgtasks.recent_visited_task import recent_visited_task
from .. import BaseViewSet
from plane.db.models import UserFavorite
Expand All @@ -64,7 +64,13 @@ def get_queryset(self):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=1))
.order_by(self.request.GET.get("order_by", "-created_at"))
.order_by(
sanitize_order_by(
self.request.GET.get("order_by", "-created_at"),
VIEW_ORDER_BY_ALLOWLIST,
"-created_at",
)
)
.distinct()
)

Expand Down
8 changes: 6 additions & 2 deletions apps/api/plane/app/views/workspace/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.order_queryset import ACTIVITY_ORDER_BY_ALLOWLIST, order_issue_queryset, sanitize_order_by
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
Expand Down Expand Up @@ -391,7 +391,11 @@ def get(self, request, slug, user_id):
queryset = queryset.filter(project__in=projects)

return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
order_by=sanitize_order_by(
request.GET.get("order_by", "-created_at"),
ACTIVITY_ORDER_BY_ALLOWLIST,
"-created_at",
),
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data,
Expand Down
96 changes: 96 additions & 0 deletions apps/api/plane/utils/order_queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,104 @@
PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"]
STATE_ORDER = ["backlog", "unstarted", "started", "completed", "cancelled"]

# ---------------------------------------------------------------------------
# order_by allowlists — one per model/endpoint family
# All contain bare field names (no leading '-'); the sanitizer strips the
# prefix before looking up, so descending variants are implicitly covered.
# Prevents ORM order_by injection via user-supplied query params
# (GHSA-2r95-c453-vxmr / GHSA-w45q-6m65-9498).
# ---------------------------------------------------------------------------

ISSUE_ORDER_BY_ALLOWLIST = frozenset({
"created_at",
"updated_at",
"sequence_id",
"sort_order",
"target_date",
"start_date",
"completed_at",
"archived_at",
"priority",
"state__name",
"state__group",
"assignees__first_name",
"labels__name",
"issue_module__module__name",
})

# IntakeIssue queryset — fields are prefixed with `issue__` for the join.
INTAKE_ISSUE_ORDER_BY_ALLOWLIST = frozenset({
"issue__created_at",
"issue__updated_at",
"issue__sequence_id",
"issue__sort_order",
"issue__target_date",
"issue__start_date",
"issue__priority",
"issue__state__name",
"created_at",
"updated_at",
"status",
})

# IssueActivity queryset (user activity, workspace member activity).
ACTIVITY_ORDER_BY_ALLOWLIST = frozenset({
"created_at",
"updated_at",
})

# Project list queryset.
PROJECT_ORDER_BY_ALLOWLIST = frozenset({
"created_at",
"updated_at",
"name",
"network",
"sort_order",
})

# Saved view (IssueView) list queryset.
VIEW_ORDER_BY_ALLOWLIST = frozenset({
"created_at",
"updated_at",
"name",
})

# Notification queryset.
NOTIFICATION_ORDER_BY_ALLOWLIST = frozenset({
"created_at",
"updated_at",
})


def sanitize_order_by(value, allowed_fields, default="-created_at"):
"""Return a safe ordering string derived from *value*.

Strips at most one leading '-' (descending indicator), checks the bare
field name against *allowed_fields*, and reconstructs the value. Inputs
with multiple leading dashes (e.g. ``--created_at``) are rejected and
*default* is returned instead, preventing both allowlist bypass and
malformed tokens from reaching ``.order_by()``.

Call this before passing any user-supplied ordering string to .order_by() or
a paginator to prevent ORM order_by injection.
"""
if not value:
return default
is_desc = value.startswith("-")
bare = value[1:] if is_desc else value
# Reject malformed prefixes like "--created_at".
if bare.startswith("-"):
return default
if bare not in allowed_fields:
return default
return f"-{bare}" if is_desc else bare


def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
# Reject any field that is not in the allowlist before building the queryset.
# An unrecognised value is silently replaced with the safe default so callers
# receive consistent output rather than an ORM error or data leak.
order_by_param = sanitize_order_by(order_by_param, ISSUE_ORDER_BY_ALLOWLIST, default="-created_at")
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
issue_queryset = issue_queryset.annotate(
Expand Down
Loading