Skip to content

[SECUR-243] fix(security): scope issue comment + relation endpoints to the URL project (GHSA-hvx3-58mp-5fpx) - #9531

Open
mguptahub wants to merge 3 commits into
previewfrom
secur-243/issue-subresource-idor
Open

[SECUR-243] fix(security): scope issue comment + relation endpoints to the URL project (GHSA-hvx3-58mp-5fpx)#9531
mguptahub wants to merge 3 commits into
previewfrom
secur-243/issue-subresource-idor

Conversation

@mguptahub

@mguptahub mguptahub commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes a cross-tenant IDOR (CWE-639) on three issue sub-resource write handlers. ProjectEntityPermission and the allow_permission decorator validate only that the caller is an active member of the URL's project_id — neither validates that the sibling issue_id path parameter belongs to that project or workspace. Any authenticated user who is a member of one project on the instance could therefore reach issues in other projects and other workspaces by passing their UUID in the URL.

Advisory: GHSA-hvx3-58mp-5fpx (HIGH, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N), reported by @Avinash9102. Ticket: SECUR-243.

This is present in released v1.4.0 — it was not part of the v1.4.0 security batch. Verified against the v1.4.0 tag.

What was wrong

Handler Bug Impact
IssueCommentViewSet.create Issue.objects.get(pk=issue_id) — no workspace/project filter Cross-tenant read. The 201 response serializes the foreign issue's full issue_detailname, description_json, description_html, priority, start_date, target_date, sequence_id — back to the caller, and plants a comment on an issue in a workspace they never joined
IssueRelationViewSet.create Request-body issues were workspace-scoped, but the URL issue_id used as the other side of the relation was not Cross-tenant write: arbitrary foreign issue linked as the counterpart. The earlier scoping fix covered one side of the relationship and missed the other
IssueRelationViewSet.remove_relation Filtered IssueRelation on workspace__slug only Found while reviewing the two above. A member of one project could delete relations between two issues of a sibling project in the same workspace

The fix

All three bind the lookup to pk=issue_id, workspace__slug=slug, project_id=project_id and return 404 otherwise — the pattern already established by sub_issue.py's get() and PagesDescriptionViewSet.retrieve.

For remove_relation, only the URL issue is project-bound; the IssueRelation row stays workspace-scoped on purpose, because relations legitimately span projects and either participant's project must be able to remove them.

Also fixes a pre-existing 500 in remove_relation: it did .first() and then called .delete() on None (AttributeError → generic 500) when no relation matched. Now a 404.

Deliberately not in this PR

  • SubIssuesEndpoint.post — the advisory's third vector. Already fixed by open PR [WEB-8352] fix(security): scope SubIssuesEndpoint to the URL project (GHSA-gxhv-fw9x-2pg3) #9466 (WEB-8352, GHSA-gxhv-fw9x-2pg3); no point duplicating it.
  • Five adjacent endpoints with the same bug classreaction.py, link.py, subscriber.py, CommentReactionViewSet.create, attachment.py. All verified reachable cross-workspace, but impact is materially lower: their responses carry no victim content and rows land with the attacker's project_id, so the victim's project-scoped querysets and notification_task hide them (pollution, not disclosure). Tracked in SECUR-244.

Behaviour change worth reviewing

comment.py uses Issue.objects and relation.py uses Issue.issue_objects. That asymmetry is intentional, not an oversight — IssueManager additionally excludes triage-state, archived, draft, and archived-project issues:

  • Comments stay on Issue.objects — exactly behaviour-preserving. Intake/triage, archived and draft issues must remain commentable (intake depends on it).
  • Relations use issue_objects — matches the file's existing convention (the body issues filter) and SubIssuesEndpoint. This narrows behaviour: creating or removing a relation whose URL issue is triage/archived/draft now 404s where it previously succeeded. No first-party regression — the web client renders no relations UI in intake, passes disabled={isArchived} on archived detail, and drafts have no relation UI — but a non-web app-API consumer doing this would see the change. Flagging explicitly in case reviewers prefer Issue.objects here for strict parity.

New tests pin this boundary in both directions, so a future "tidy these to match" change can't silently break commenting on intake items.

Testing

12 new contract tests in plane/tests/contract/app/test_issue_subresource_scope_app.py, covering each handler with a cross-workspace case, a same-workspace-other-project case, and a positive control.

Fail-before verified by reverting each fix and re-running:

  • comment create → 201 with "Confidential victim issue" and "Secret roadmap detail" present in the response body
  • relation create → 201 with an IssueRelation row written against the victim issue
  • relation removal → 204 with deleted_at set on the sibling project's relation
  • missing relation → 500 AttributeError: 'NoneType' object has no attribute 'delete'

Positive controls stayed green throughout, so the tests exercise the scoping rather than the plumbing — and the 404s are attributable to the new lookups, not to a permission denial that would have happened anyway (the URL project is always one the attacker is a member of).

Full suite: 526 passed (plane/tests/contract + plane/tests/unit). ruff check and ruff format --check clean.

Follow-ups

  • EE parity — plane-ee vendors its own copy of the API and is not auto-synced; these three handlers are likely vulnerable there too. Belongs to Epic WEB-8293.
  • Systemic — this bug class recurs while ProjectEntityPermission ignores issue_id. Worth validating issue-to-project binding at the permission layer rather than per view. Also noted in SECUR-244: permissions/base.py's creator=True branch filters on created_by with no workspace/project scope, which is safe only because today's callers re-scope themselves.

The advisory stays in triage until this ships in a release — it is not eligible for the v1.4.0 CVE batch.

Summary by CodeRabbit

  • Bug Fixes

    • Improved issue comment and relation access checks to ensure requests are limited to the specified workspace and project.
    • Cross-project or cross-workspace requests now return a 404 without modifying data.
    • Missing issue relations now return a 404 instead of an error.
    • Prevented relation operations on archived issues while preserving supported comment behavior.
  • Tests

    • Added coverage for valid and invalid issue sub-resource operations, including boundary and data-protection scenarios.

mguptahub and others added 2 commits August 3, 2026 14:29
…he URL project

ProjectEntityPermission and allow_permission validate only that the caller is an
active member of the URL's project_id. Neither validates that the sibling issue_id
path parameter belongs to that project or workspace, and two write handlers used
issue_id unscoped:

- IssueCommentViewSet.create resolved the issue with Issue.objects.get(pk=issue_id).
  Any member of any one project could comment on an issue in another project or
  workspace, and the 201 response serialized that issue's full issue_detail — name,
  description_json, description_html, priority, dates, sequence_id — back to the
  caller, making this a cross-tenant read as well as a write.

- IssueRelationViewSet.create workspace-scoped the request-body issues list but used
  the URL issue_id unscoped as the other side of the relation, so the earlier scoping
  fix covered one side of the relationship and missed the other.

Both lookups are now bound to workspace__slug + project_id and 404 otherwise,
matching the pattern already used by sub_issue.py get() and PagesDescriptionViewSet.

The third vector in the advisory (SubIssuesEndpoint.post) is already addressed by
open PR #9466 and is deliberately not touched here.

Adds contract regression coverage for both endpoints: cross-workspace and
same-workspace-other-project both 404 with nothing written and no detail leaked,
plus positive controls for the in-project paths.

Advisory: GHSA-hvx3-58mp-5fpx
Co-authored-by: Plane AI <noreply@plane.so>
…oject

Follow-up from adversarial review of the create-path fix: remove_relation had the
same gap on the delete side. It filtered IssueRelation on workspace__slug only and
never checked that the URL issue_id belongs to the URL project, so a member of one
project could delete relations between two issues of a sibling project in the same
workspace — verified 204 with deleted_at set on the sibling project's row.

Applies the same participant binding as create(): the URL issue must live in the URL
project. The IssueRelation row itself stays workspace-scoped, because relations
legitimately span projects and either participant's project may remove them.

Also returns 404 instead of a 500 when no relation matches — the handler did
.first() and then called .delete() on None (AttributeError -> generic 500).

Adds coverage for the sibling-project delete, the missing-relation 404, and a
positive control, plus tests pinning the IssueManager boundary: comment.py
deliberately uses Issue.objects (intake/triage, archived and draft issues must stay
commentable) while relation.py uses Issue.issue_objects (matching the body `issues`
filter and SubIssuesEndpoint). That axis was previously untested, so "tidying" the
two to match would have silently broken commenting on intake items.

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

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 945d48fb-ff7d-442a-a18c-e6917d2e9e9a

📥 Commits

Reviewing files that changed from the base of the PR and between 96257d1 and 9fd1d53.

📒 Files selected for processing (2)
  • apps/api/plane/app/views/issue/relation.py
  • apps/api/plane/tests/contract/app/test_issue_subresource_scope_app.py

📝 Walkthrough

Walkthrough

The API now scopes issue comment and relation operations to the URL workspace and project. Invalid issue bindings and missing relations return 404 responses. Contract tests cover cross-scope requests, valid operations, deletion behavior, issue states, and malformed input.

Changes

Issue subresource scope

Layer / File(s) Summary
Issue binding for comment and relation operations
apps/api/plane/app/views/issue/comment.py, apps/api/plane/app/views/issue/relation.py
Comment creation and relation listing or creation now verify that the issue belongs to the URL workspace and project.
Relation removal validation
apps/api/plane/app/views/issue/relation.py
Relation removal validates the issue binding and returns 404 when the relation does not exist.
Scope and boundary contract tests
apps/api/plane/tests/contract/app/test_issue_subresource_scope_app.py
Tests cover tenant fixtures, cross-scope rejection, valid operations, soft deletion, issue-state boundaries, and malformed relation input.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

  • makeplane/plane#9269 — Both changes scope issue-related endpoint lookups and mutations, including relation operations.
  • makeplane/plane#9442 — Both changes add project and workspace binding checks with 404 responses and contract tests.
  • makeplane/plane#9498 — Both changes scope issue comment access to the requested workspace and project.

Suggested reviewers: dheeru0198, pablohashescobar

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the security fix and the affected issue comment and relation endpoints.
Description check ✅ Passed The description explains the vulnerability, fix, scope, behavior changes, tests, advisory, and follow-up work in sufficient detail.
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 secur-243/issue-subresource-idor

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 addresses SECUR-243 / GHSA-hvx3-58mp-5fpx by hard-binding issue sub-resource handlers (comments + issue relations) to the URL workspace + project, preventing cross-project / cross-workspace IDOR via an unscoped issue_id.

Changes:

  • Scope IssueCommentViewSet.create issue lookup to (pk, workspace__slug, project_id) and return 404 when mismatched.
  • Scope IssueRelationViewSet.create and remove_relation by validating the URL issue_id belongs to (workspace__slug, project_id), and return 404 when missing relations would previously 500.
  • Add contract tests to cover cross-workspace and same-workspace-other-project cases, plus manager-boundary behavior for archived/draft issues.

Reviewed changes

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

File Description
apps/api/plane/app/views/issue/comment.py Scopes comment creation to the URL project/workspace to prevent IDOR and leakage via the 201 response payload.
apps/api/plane/app/views/issue/relation.py Adds URL-issue binding for relation create/remove and returns 404 instead of 500 when no matching relation exists.
apps/api/plane/tests/contract/app/test_issue_subresource_scope_app.py Introduces contract coverage for the IDOR regression and documents the intentional Issue.objects vs Issue.issue_objects behavior boundary.

Comment thread apps/api/plane/app/views/issue/relation.py
Comment thread apps/api/plane/app/views/issue/relation.py

@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/app/views/issue/relation.py (2)

220-228: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the issue-binding check into a shared helper.

The Issue.issue_objects.filter(pk=issue_id, workspace__slug=slug, project_id=project_id).exists() check at Line 226 duplicates the identical check in remove_relation at Line 289. Extract this into one private method both call. A single implementation reduces the risk that a future edit updates one check but not the other, which would reopen the scoping gap this PR fixes.

🤖 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/app/views/issue/relation.py` around lines 220 - 228, Extract
the repeated issue-binding query into a private helper on the surrounding view
class, preserving the workspace slug, project ID, and issue ID filters. Update
both the shown relation-creation path and remove_relation to call this helper
and return the existing 404 response when it fails.

283-300: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good fix for the missing-relation 500; extract the duplicated binding check.

The .first() plus is None check correctly converts the previous None.delete() crash into a 404, matching test_missing_relation_404s_rather_than_500.

The issue-binding check at Line 289 duplicates the one in create at Line 226. Extract both into a shared private method to keep this security-relevant check in one place.

🤖 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/app/views/issue/relation.py` around lines 283 - 300, Extract
the duplicated issue-binding validation from the create and delete flows into a
shared private method on the view, using the existing issue ID, workspace slug,
and project ID inputs. Replace both inline checks with calls to that method
while preserving the current 404 response behavior, and leave the
missing-relation `.first()`/`None` handling unchanged.
🤖 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/app/views/issue/relation.py`:
- Around line 220-228: Extract the repeated issue-binding query into a private
helper on the surrounding view class, preserving the workspace slug, project ID,
and issue ID filters. Update both the shown relation-creation path and
remove_relation to call this helper and return the existing 404 response when it
fails.
- Around line 283-300: Extract the duplicated issue-binding validation from the
create and delete flows into a shared private method on the view, using the
existing issue ID, workspace slug, and project ID inputs. Replace both inline
checks with calls to that method while preserving the current 404 response
behavior, and leave the missing-relation `.first()`/`None` handling unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 211393e6-0511-48be-bc68-83ad107a4866

📥 Commits

Reviewing files that changed from the base of the PR and between 65f4a99 and 96257d1.

📒 Files selected for processing (3)
  • apps/api/plane/app/views/issue/comment.py
  • apps/api/plane/app/views/issue/relation.py
  • apps/api/plane/tests/contract/app/test_issue_subresource_scope_app.py

Addresses Copilot review on #9531. IssueRelationViewSet.list had the same unscoped
issue_id as the write handlers: it filtered relations on workspace__slug only, so a
member of project A could list the relations of an issue in project B of the same
workspace and receive that issue's name, priority, sequence_id, assignee_ids and
label_ids. Verified 200 with the foreign issue's name in the body before the fix.

Extracts the binding into a module-level issue_in_project() helper (per CodeRabbit)
now that list, create and remove_relation all need it — one place to get right, and
the next handler added to this file has an obvious thing to call.

Adds contract coverage for the list path (cross-project 404 with no name leak, plus
an in-project positive control), extends the IssueManager boundary tests to
remove_relation and list so all handlers are pinned to the same manager, and pins
that a non-UUID related_issue is a 400 rather than a 500 — Copilot flagged that as a
500 risk, but Django raises ValidationError on UUID coercion and
BaseViewSet.handle_exception already converts it. The test locks that in.

Co-authored-by: Plane AI <noreply@plane.so>
Copilot AI review requested due to automatic review settings August 3, 2026 11:07
@mguptahub

Copy link
Copy Markdown
Collaborator Author

Thanks — one of these was a real hole I'd missed, one doesn't reproduce. Addressed in 9fd1d53.

1. list() unscoped — valid, fixed ✅

Correct, and the more serious of the two. I reproduced it before fixing: a member of project A calling GET .../issues/<project-B-issue-id>/issue-relation/ got 200 with the foreign issue's name, priority, sequence_id, assignee_ids and label_ids in the body. Same root cause as the write handlers, and I'd only looked at writes.

list() now applies the same binding and 404s. Two new contract tests cover it (cross-project 404 with no name leak, plus an in-project positive control), and I verified both fail without the fix — the failure output literally contains 'name': 'Sibling relation target'.

2. Non-UUID related_issue → 500 — doesn't reproduce ❌

I probed this directly rather than reasoning about it:

POST .../remove-relation/  {"related_issue": "not-a-uuid"}
-> 400 {'error': 'Please provide valid detail'}

Django raises django.core.exceptions.ValidationError on the UUID coercion, and BaseViewSet.handle_exception (app/views/base.py:85-89) already maps that to a 400 — base.py imports Django's ValidationError, not DRF's, so the branch does catch it. Same applies to the pk__in=issues filter in create.

No code change, but I added a test pinning the 400 so a future change to that handler can't quietly turn malformed input into a 500. Missing key is also covered: {} → 404 via the new relation guard, not a crash.

3. remove_relation boundary test — valid, added ✅

Fair point, and it applied to list() too once that was fixed. The IssueManager boundary is now pinned on all three relation handlers (create, remove, list) plus both comment cases, so the Issue.objects vs Issue.issue_objects asymmetry can't drift in either direction:

  • comments on archived/draft issues → 201 (must stay commentable; intake depends on it)
  • relation create/remove/list with an archived URL issue → 404 (the issue_objects narrowing, deliberate)

Also, per CodeRabbit

Extracted the binding into a module-level issue_in_project(issue_id, slug, project_id) helper now that three handlers in this file need it — one place to get right, and the next handler added here has an obvious thing to call.


Suite: 17 contract tests in this file, 531 passed overall (plane/tests/contract + plane/tests/unit), ruff clean on all changed files.

The two remaining ruff F401s in sub_issue.py (Func, Q unused) are pre-existing on preview and in a file this PR doesn't touch — #9466 is the one editing it, so they belong there rather than here.

Worth noting the advisory itself listed three vectors; this PR now closes four, because list and remove_relation were found by reading around the reported endpoints rather than trusting the report's inventory. Adjacent endpoints with the same bug class (reaction.py, link.py, subscriber.py, CommentReactionViewSet.create, attachment.py) are tracked separately in SECUR-244 — lower impact, since those responses carry no victim content and rows land with the attacker's project_id.

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

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

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