Skip to content

[WEB-8017] fix(security): sanitize order_by on external REST API list endpoints#9348

Open
mguptahub wants to merge 1 commit into
previewfrom
web-8017/order-by-injection-external-api
Open

[WEB-8017] fix(security): sanitize order_by on external REST API list endpoints#9348
mguptahub wants to merge 1 commit into
previewfrom
web-8017/order-by-injection-external-api

Conversation

@mguptahub

@mguptahub mguptahub commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes a partial bypass of WEB-7813 (PR #9292, GHSA-2r95 / GHSA-w45q). Two external REST API list endpoints passed a raw order_by query parameter straight to Django's .order_by():

  • GET /api/v1/workspaces/<slug>/projects/ProjectListCreateAPIEndpoint.get
  • GET /api/v1/workspaces/<slug>/projects/<project_id>/issues/IssueListCreateAPIEndpoint.get

Because Django resolves __-separated relational paths, an attacker with an API token could:

  • Blind data exfiltration: order by sensitive columns on related tables (created_by__password, created_by__token, created_by__email, workspace__owner__password) to build an ordering oracle.
  • DoS: supply an unknown field (order_by=not_a_field) → unhandled FieldError → HTTP 500.

Advisory: GHSA-p885-6jpg-cr2p (medium).

Fix

Route both endpoints through the existing sanitize_order_by() helper with the appropriate allowlist:

  • PROJECT_ORDER_BY_ALLOWLIST (default sort_order) for the project list
  • ISSUE_ORDER_BY_ALLOWLIST (default -created_at) for the work-item list

This mirrors how order_issue_queryset() already sanitizes at its top. Non-allowlisted values collapse to the safe default; every legitimate ordering (priority, state__group, labels__name, assignees__first_name, -created_at, name, network, …) is unchanged.

Tests

  • Unit (tests/unit/utils/test_order_by_sanitize.py): the two allowlists neutralise the disclosed payloads (incl. created_by__password, --created_at, id; drop table) to the default, and preserve every legitimate value.
  • Contract (tests/contract/api/test_projects.py, test_issues.py): both endpoints return 200 (not 500) for injected fields, and legit ordering still works.
  • Fail-before verified via git stash — both endpoint tests return 500 without the fix. 52 passed with it.

Verification

  • ruff check clean
  • python manage.py check clean
  • Full new-test suite: 52 passed

Summary by CodeRabbit

  • Bug Fixes
    • Improved sorting safety in project and issue lists by handling invalid or unexpected order_by values more gracefully.
    • Prevented malformed or relational sorting inputs from causing errors, while keeping valid sorting options working as expected.
    • Added regression coverage to ensure list endpoints continue to respond successfully when unsafe sorting parameters are provided.

… endpoints

Close a partial bypass of WEB-7813 (GHSA-2r95 / GHSA-w45q): the external
REST API project-list and work-item-list endpoints passed a raw order_by
query parameter to Django's .order_by(). Because Django resolves
__-separated relational paths, an attacker could order by sensitive
columns on related tables (created_by__password / token / email) to build
a blind ordering oracle, or crash the endpoint (HTTP 500) with an unknown
field.

Route both endpoints through the existing sanitize_order_by() helper with
the appropriate allowlist (PROJECT_ORDER_BY_ALLOWLIST, default sort_order;
ISSUE_ORDER_BY_ALLOWLIST, default -created_at), mirroring how
order_issue_queryset() already sanitizes. Non-allowlisted values collapse
to the safe default; legitimate orderings are unchanged.

Adds unit tests (allowlist neutralisation + passthrough) and contract
tests asserting both endpoints return 200 (not 500) for injected fields;
fail-before verified via git stash.

Advisory: GHSA-p885-6jpg-cr2p

Co-authored-by: Plane AI <noreply@plane.so>
Copilot AI review requested due to automatic review settings July 3, 2026 10:35
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR sanitizes the order_by query parameter in the issue and project list API endpoints using a sanitize_order_by() utility with per-endpoint allowlists, replacing raw query values passed directly to Django ORM order_by(). Contract and unit tests are added to validate the fix against injection payloads.

Changes

Order-by injection sanitization

Layer / File(s) Summary
Sanitize issue list ordering
apps/api/plane/api/views/issue.py
Imports ISSUE_ORDER_BY_ALLOWLIST and sanitize_order_by; order_by_param is now computed via sanitize_order_by(...) defaulting to -created_at instead of using raw request.GET["order_by"].
Sanitize project list ordering
apps/api/plane/api/views/project.py
Imports PROJECT_ORDER_BY_ALLOWLIST and sanitize_order_by; replaces direct .order_by(request.GET.get("order_by", "sort_order")) with a sanitized/allowlisted expression.
Issue list contract tests
apps/api/plane/tests/contract/api/test_issues.py
New test module with project/state/issue fixtures and TestIssueListOrderByInjection, verifying invalid, relational, and legitimate order_by values all return HTTP 200.
Project list contract tests
apps/api/plane/tests/contract/api/test_projects.py
New tests asserting invalid and relational order_by values return HTTP 200 instead of a 500 error.
sanitize_order_by unit tests
apps/api/plane/tests/unit/utils/test_order_by_sanitize.py
New unit tests covering injection payloads, allowlisted ascending/descending values, empty strings, and None fallback behavior against per-endpoint defaults.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

  • makeplane/plane#9292: Introduces and uses the same sanitize_order_by()/per-endpoint *_ORDER_BY_ALLOWLIST mechanism referenced by this PR's changes.

Suggested reviewers: pablohashescobar, dheeru0198

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the security fix and the affected order_by behavior on external REST API list endpoints.
Description check ✅ Passed The description is detailed and covers summary, fix, tests, and verification, though it omits some template sections like type of change and references.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch web-8017/order-by-injection-external-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes a security gap where two external REST API list endpoints accepted a raw order_by query parameter and passed it directly to Django’s .order_by(), enabling relational-path traversal attempts (ordering oracle) and unhandled FieldError → HTTP 500. The fix routes both endpoints through the existing sanitize_order_by() helper with the appropriate allowlists, and adds unit + contract regressions to prevent recurrence.

Changes:

  • Sanitize order_by in the external project list endpoint using PROJECT_ORDER_BY_ALLOWLIST.
  • Sanitize order_by in the external issue list endpoint using ISSUE_ORDER_BY_ALLOWLIST before its ordering branch logic.
  • Add unit tests for sanitizer allowlist behavior and contract tests ensuring injected/invalid order_by values return 200.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
apps/api/plane/api/views/project.py Applies sanitize_order_by() to the external project list ordering.
apps/api/plane/api/views/issue.py Applies sanitize_order_by() to the external issue list ordering before branch logic.
apps/api/plane/tests/unit/utils/test_order_by_sanitize.py Adds unit regressions ensuring allowlists default unsafe/malformed values and preserve legitimate orderings.
apps/api/plane/tests/contract/api/test_projects.py Adds contract regressions that invalid/relational order_by values don’t trigger 500s.
apps/api/plane/tests/contract/api/test_issues.py Adds contract regressions for the issue list endpoint covering invalid/relational/legitimate order_by.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/api/plane/tests/contract/api/test_projects.py (1)

194-200: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant re-query after create.

Project.objects.create(...) return value is discarded, then re-fetched via Project.objects.get(identifier="OP") on the next line for ProjectMember.objects.create. Capture the created instance directly instead of issuing an extra query.

♻️ Proposed fix
-        Project.objects.create(
+        project = Project.objects.create(
             name="Ordered Project",
             identifier="OP",
             workspace=workspace,
             created_by=create_user,
         )
-        ProjectMember.objects.create(project=Project.objects.get(identifier="OP"), member=create_user, role=20)
+        ProjectMember.objects.create(project=project, member=create_user, role=20)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/tests/contract/api/test_projects.py` around lines 194 - 200,
The Project creation flow in test_projects.py does an unnecessary re-query right
after Project.objects.create, then uses Project.objects.get(identifier="OP") for
ProjectMember.objects.create. Capture the instance returned by
Project.objects.create in the existing project setup and pass that object
directly to ProjectMember.objects.create, avoiding the extra database hit.
apps/api/plane/tests/unit/utils/test_order_by_sanitize.py (1)

99-114: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Missing coverage for issue_module__module__name.

ISSUE_ORDER_BY_ALLOWLIST also includes issue_module__module__name, which isn't exercised in this parametrize list. Not a correctness bug, but adding it would make the test fully mirror the allowlist contract.

♻️ Suggested addition
         "state__name",
         "state__group",
         "assignees__first_name",
         "labels__name",
+        "issue_module__module__name",
     ],
 )

The unit tests validate that injection payloads fall back to endpoint defaults and that allowlisted (including descending variants) values pass; these allowlists are the contract inputs those tests use.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/tests/unit/utils/test_order_by_sanitize.py` around lines 99 -
114, Add test coverage for the missing allowlisted order-by field by including
issue_module__module__name in the parametrize list in test_order_by_sanitize.py.
Keep the existing sanitize/allowlist assertions in the same test so the Order By
validation continues to exercise every value in ISSUE_ORDER_BY_ALLOWLIST,
including this nested field.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/plane/tests/contract/api/test_projects.py`:
- Around line 194-200: The Project creation flow in test_projects.py does an
unnecessary re-query right after Project.objects.create, then uses
Project.objects.get(identifier="OP") for ProjectMember.objects.create. Capture
the instance returned by Project.objects.create in the existing project setup
and pass that object directly to ProjectMember.objects.create, avoiding the
extra database hit.

In `@apps/api/plane/tests/unit/utils/test_order_by_sanitize.py`:
- Around line 99-114: Add test coverage for the missing allowlisted order-by
field by including issue_module__module__name in the parametrize list in
test_order_by_sanitize.py. Keep the existing sanitize/allowlist assertions in
the same test so the Order By validation continues to exercise every value in
ISSUE_ORDER_BY_ALLOWLIST, including this nested field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 54aac86d-1c60-4eab-82e2-01826b31eef2

📥 Commits

Reviewing files that changed from the base of the PR and between 7fbf14a and 4376c5a.

📒 Files selected for processing (5)
  • apps/api/plane/api/views/issue.py
  • apps/api/plane/api/views/project.py
  • apps/api/plane/tests/contract/api/test_issues.py
  • apps/api/plane/tests/contract/api/test_projects.py
  • apps/api/plane/tests/unit/utils/test_order_by_sanitize.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants