Skip to content

[WEB-8012] fix: prevent ORM group_by/sub_group_by injection in issue endpoints#9347

Open
mguptahub wants to merge 1 commit into
previewfrom
web-8012/group-by-injection-fix
Open

[WEB-8012] fix: prevent ORM group_by/sub_group_by injection in issue endpoints#9347
mguptahub wants to merge 1 commit into
previewfrom
web-8012/group-by-injection-fix

Conversation

@mguptahub

@mguptahub mguptahub commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Unauthenticated public deploy-board endpoint (ProjectIssuesPublicEndpoint, plane/space/views/issue.py) reads group_by/sub_group_by query params raw and feeds them into GroupedOffsetPaginator/SubGroupedOffsetPaginator, which use the value directly as an ORM field name in F(), .values(), .order_by(), and Window partition_by — an anonymous caller can crash the endpoint (HTTP 500 DoS) or force a blind relational-traversal oracle (e.g. sub_group_by=created_by__password).
  • Same field-name-injection class as the order_by fix (GHSA-2r95/GHSA-w45q, WEB-7813), which never extended to group_by/sub_group_by. The identical unvalidated pattern also exists on 5 authenticated GUEST-reachable endpoints (app/views/issue/base.py, issue/archive.py, cycle/issue.py, module/issue.py, workspace/user.py) — all funnel through the same two paginator classes, so this fixes all 6 in one place.
  • Adds ISSUE_GROUP_BY_ALLOWLIST to plane/utils/order_queryset.py and validates group_by_field_name/sub_group_by_field_name inside BasePaginator.paginate() — the single chokepoint all 6 call sites funnel through — raising ParseError (→ HTTP 400) on an invalid field, matching the existing idiom already used in that function for bad per_page/cursor values.
  • Allowlist is verified against issue_group_values() in both plane/utils/grouper.py and plane/space/utils/grouper.py — the exact fields both already resolve safely.

Test plan

  • Added apps/api/plane/tests/unit/utils/test_order_queryset.py — allowlist contents + rejection of injection-style values
  • Added apps/api/plane/tests/unit/utils/test_paginator.py — invalid group_by/sub_group_by raise ParseError; valid values still pass through; plain (non-grouped) pagination unaffected
  • Verified new tests fail against pre-fix code (git stash on the paginator.py fix only) and pass with the fix
  • Full unit suite passes (285 passed) via docker compose -f docker-compose-test.yml run --rm api-tests pytest plane/tests/unit -m unit
  • ruff check clean on all changed files
  • python manage.py check — no issues

https://claude.ai/code/session_01PsfB3aMwtYHFWrDqJTr1aW

Summary by CodeRabbit

  • Bug Fixes

    • Tightened validation for grouped pagination inputs so only approved fields are accepted.
    • Rejected invalid, empty, or unsafe grouping values earlier in the request flow.
    • Kept normal pagination behavior unchanged when no grouping is provided.
  • Tests

    • Added regression coverage for allowed grouping fields and blocked injection-style inputs.
    • Added pagination tests for valid/invalid group-by and sub-group-by combinations.

…endpoints

Add ISSUE_GROUP_BY_ALLOWLIST and validate group_by_field_name/
sub_group_by_field_name in BasePaginator.paginate() — the single chokepoint
all GroupedOffsetPaginator/SubGroupedOffsetPaginator callers funnel through
(the unauthenticated public deploy-board endpoint plus 5 GUEST-reachable
authenticated endpoints). Invalid fields now raise ParseError (HTTP 400)
instead of reaching F()/.values()/.order_by()/Window partition_by as a raw
ORM field name, which previously let an anonymous caller crash the endpoint
or force a blind relational-traversal oracle (GHSA-wwgj-929g-42cm).

Same field-name-injection class as the order_by fix (GHSA-2r95/GHSA-w45q,
WEB-7813), which never extended to group_by/sub_group_by.

Closes WEB-8012

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

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an ISSUE_GROUP_BY_ALLOWLIST frozenset constant and enforces it in BasePaginator.paginate for group_by_field_name and sub_group_by_field_name, raising ParseError on invalid values. Adds unit tests covering the allowlist, sanitize_order_by, and paginator validation behavior.

Changes

Group-by allowlist enforcement

Layer / File(s) Summary
Allowlist constant
apps/api/plane/utils/order_queryset.py
Adds ISSUE_GROUP_BY_ALLOWLIST frozenset defining permitted ORM field names for group_by/sub_group_by.
Paginator enforcement
apps/api/plane/utils/paginator.py
Imports the allowlist and validates group_by_field_name/sub_group_by_field_name against it, raising ParseError for invalid fields.
Allowlist and sanitize_order_by tests
apps/api/plane/tests/unit/utils/test_order_queryset.py
Adds tests verifying the exact allowlist contents, rejection of injection-style/invalid fields, and sanitize_order_by pass-through/default behavior.
Paginator validation tests
apps/api/plane/tests/unit/utils/test_paginator.py
Adds stub paginator/request helpers and tests validating ParseError on invalid fields, pass-through of valid fields, and unaffected behavior when group_by is omitted.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant BasePaginator
  participant ISSUE_GROUP_BY_ALLOWLIST
  Client->>BasePaginator: paginate(group_by_field_name, sub_group_by_field_name)
  BasePaginator->>ISSUE_GROUP_BY_ALLOWLIST: check group_by_field_name membership
  ISSUE_GROUP_BY_ALLOWLIST-->>BasePaginator: valid/invalid
  alt invalid
    BasePaginator-->>Client: raise ParseError
  else valid
    BasePaginator->>ISSUE_GROUP_BY_ALLOWLIST: check sub_group_by_field_name membership
    ISSUE_GROUP_BY_ALLOWLIST-->>BasePaginator: valid/invalid
    BasePaginator-->>Client: paginated response
  end
Loading

Suggested reviewers: pablohashescobar, dheeru0198

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: blocking ORM field-name injection in issue group_by/sub_group_by endpoints.
Description check ✅ Passed The description is detailed and covers the fix and test plan, but it omits the template's type-of-change and references sections.
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-8012/group-by-injection-fix

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.

@makeplane

makeplane Bot commented Jul 3, 2026

Copy link
Copy Markdown

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

Closes an ORM field-name injection vector in issue listing endpoints by validating group_by/sub_group_by before those values can reach Django ORM APIs (F(), .values(), .order_by(), Window(partition_by=...)) through the grouped paginator implementations.

Changes:

  • Added an explicit ISSUE_GROUP_BY_ALLOWLIST and enforced it in BasePaginator.paginate() for group_by and sub_group_by inputs (returning HTTP 400 via ParseError on invalid values).
  • Added unit tests to lock the allowlist contents and verify invalid grouped fields are rejected while non-grouped pagination remains unaffected.

Reviewed changes

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

File Description
apps/api/plane/utils/paginator.py Validates group_by/sub_group_by against a central allowlist before constructing grouped paginator instances.
apps/api/plane/utils/order_queryset.py Introduces ISSUE_GROUP_BY_ALLOWLIST alongside existing ordering allowlists to prevent ORM field-name injection.
apps/api/plane/tests/unit/utils/test_paginator.py Adds regression tests ensuring invalid grouped fields raise ParseError and do not instantiate the paginator.
apps/api/plane/tests/unit/utils/test_order_queryset.py Adds tests asserting allowlist contents and basic rejection of injection-style values; confirms sanitize_order_by behavior unchanged.

@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/unit/utils/test_order_queryset.py (1)

21-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Test hardcodes the expected set instead of validating against the actual safe-fields source.

test_allowlist_matches_known_safe_group_fields duplicates the allowlist contents as a literal set. If ISSUE_GROUP_BY_ALLOWLIST and issue_group_values() in grouper.py drift apart in the future, this test won't catch it since it only compares the constant against itself (restated), not against the actual grouping fields used by the paginator's data source.

Consider deriving expected from the grouper module's known safe fields (or adding a companion test that cross-checks ISSUE_GROUP_BY_ALLOWLIST against issue_group_values() output) to keep the allowlist and paginator in sync automatically.

🤖 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_queryset.py` around lines 21 - 35,
The test `test_allowlist_matches_known_safe_group_fields` is duplicating the
safe group-by values as a hardcoded set, so it won’t detect drift between
`ISSUE_GROUP_BY_ALLOWLIST` and the real source of truth. Update this test to
derive the expected values from the grouper module, ideally by comparing
`ISSUE_GROUP_BY_ALLOWLIST` against the output of `issue_group_values()` in
`grouper.py` (or another shared safe-fields source) so the allowlist and
paginator stay aligned. Keep the assertion anchored on the
`ISSUE_GROUP_BY_ALLOWLIST` constant and the `issue_group_values` helper rather
than a literal copy of the values.
apps/api/plane/utils/paginator.py (1)

690-699: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: dedupe the two allowlist checks.

The not in ISSUE_GROUP_BY_ALLOWLIST check is repeated for group_by_field_name and sub_group_by_field_name. Could extract into a tiny helper for readability, though the duplication is minimal.

♻️ Optional dedup
+def _validate_group_by_field(field_name, kind):
+    if field_name not in ISSUE_GROUP_BY_ALLOWLIST:
+        raise ParseError(detail=f"Invalid {kind} field: {field_name}")
+
 ...
             if group_by_field_name:
-                if group_by_field_name not in ISSUE_GROUP_BY_ALLOWLIST:
-                    raise ParseError(detail=f"Invalid group_by field: {group_by_field_name}")
+                _validate_group_by_field(group_by_field_name, "group_by")

                 paginator_kwargs["group_by_field_name"] = group_by_field_name
                 paginator_kwargs["group_by_fields"] = group_by_fields
                 paginator_kwargs["count_filter"] = count_filter

                 if sub_group_by_field_name:
-                    if sub_group_by_field_name not in ISSUE_GROUP_BY_ALLOWLIST:
-                        raise ParseError(detail=f"Invalid sub_group_by field: {sub_group_by_field_name}")
+                    _validate_group_by_field(sub_group_by_field_name, "sub_group_by")
🤖 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/utils/paginator.py` around lines 690 - 699, The allowlist
validation for ISSUE_GROUP_BY_ALLOWLIST is duplicated in the paginator logic
handling group_by_field_name and sub_group_by_field_name. Refactor the checks in
the paginator flow (near the group_by/sub_group_by ParseError handling) into a
small shared helper or reusable validation function so both fields use the same
validation path while preserving the existing ParseError messages.
🤖 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/unit/utils/test_order_queryset.py`:
- Around line 21-35: The test `test_allowlist_matches_known_safe_group_fields`
is duplicating the safe group-by values as a hardcoded set, so it won’t detect
drift between `ISSUE_GROUP_BY_ALLOWLIST` and the real source of truth. Update
this test to derive the expected values from the grouper module, ideally by
comparing `ISSUE_GROUP_BY_ALLOWLIST` against the output of
`issue_group_values()` in `grouper.py` (or another shared safe-fields source) so
the allowlist and paginator stay aligned. Keep the assertion anchored on the
`ISSUE_GROUP_BY_ALLOWLIST` constant and the `issue_group_values` helper rather
than a literal copy of the values.

In `@apps/api/plane/utils/paginator.py`:
- Around line 690-699: The allowlist validation for ISSUE_GROUP_BY_ALLOWLIST is
duplicated in the paginator logic handling group_by_field_name and
sub_group_by_field_name. Refactor the checks in the paginator flow (near the
group_by/sub_group_by ParseError handling) into a small shared helper or
reusable validation function so both fields use the same validation path while
preserving the existing ParseError messages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e192acb0-86bf-4d6b-9213-38ab492636c7

📥 Commits

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

📒 Files selected for processing (4)
  • apps/api/plane/tests/unit/utils/test_order_queryset.py
  • apps/api/plane/tests/unit/utils/test_paginator.py
  • apps/api/plane/utils/order_queryset.py
  • apps/api/plane/utils/paginator.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