From 2844c1d695a7b139d6a7f98ab72c3586b5296b10 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Wed, 8 Jul 2026 16:31:58 +0530 Subject: [PATCH 1/2] [WEB-8074] fix: scope IssueListEndpoint to guest created_by IssueListEndpoint.get (/workspaces//projects//issues/list/) returned any issue whose id was passed in ?issues=, without the guest created_by restriction its sibling IssueViewSet.list enforces. A project GUEST (role=5) on a project with guest_view_all_features=False could read issues they did not author by supplying their ids (GHSA-32c7-84jc-4w67). Replicate the guest scope: when the requester is an active role=5 ProjectMember and not project.guest_view_all_features, filter the queryset to created_by=request.user. Applied to the base queryset so it flows through filtering, annotation and grouping. Contract regression tests cover the restricted guest (own-only), a full member (sees all), and a guest with guest_view_all_features enabled (sees all); fail-before verified. Co-authored-by: Plane AI --- apps/api/plane/app/views/issue/base.py | 15 ++ .../app/test_issue_list_guest_scope_app.py | 145 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 apps/api/plane/tests/contract/app/test_issue_list_guest_scope_app.py diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 3868498d1d2..d367699f536 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -93,6 +93,21 @@ def get(self, request, slug, project_id): # Base queryset with basic filters queryset = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids) + # Restrict guests without full feature access to issues they created, + # mirroring IssueViewSet.list. + project = Project.objects.get(pk=project_id, workspace__slug=slug) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + ): + queryset = queryset.filter(created_by=request.user) + # Apply filtering from filterset queryset = self.filter_queryset(queryset) diff --git a/apps/api/plane/tests/contract/app/test_issue_list_guest_scope_app.py b/apps/api/plane/tests/contract/app/test_issue_list_guest_scope_app.py new file mode 100644 index 00000000000..f48efbda7df --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_issue_list_guest_scope_app.py @@ -0,0 +1,145 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for ``IssueListEndpoint`` guest scoping. + +Regression coverage for GHSA-32c7-84jc-4w67 (WEB-8074). ``IssueListEndpoint.get`` +(``/workspaces//projects//issues/list/``) returned any issue +whose id was passed in ``?issues=``, without applying the guest ``created_by`` +restriction that its sibling ``IssueViewSet.list`` enforces. A project GUEST on a +project with ``guest_view_all_features=False`` could therefore read issues they +did not author by supplying their ids. + +The fix scopes the queryset to ``created_by=request.user`` for such guests, +mirroring ``IssueViewSet.list``. +""" + +from uuid import uuid4 + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from plane.db.models import ( + Issue, + Project, + ProjectMember, + User, + WorkspaceMember, +) + +LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/list/" + + +@pytest.fixture +def project(db, workspace, create_user): + """A project (guest_view_all_features defaults to False); owner is a member.""" + project = Project.objects.create( + name="Scoped Project", + identifier="SP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, member=create_user, workspace=workspace, role=20 + ) + return project + + +@pytest.fixture +def guest(db, workspace, project): + """An active project GUEST (role=5).""" + unique_id = uuid4().hex[:8] + user = User.objects.create( + email=f"guest-{unique_id}@plane.so", + username=f"guest_{unique_id}", + first_name="Guest", + last_name="User", + ) + user.set_password("test-password") + user.save() + WorkspaceMember.objects.create(workspace=workspace, member=user, role=5) + ProjectMember.objects.create( + project=project, member=user, workspace=workspace, role=5 + ) + return user + + +@pytest.fixture +def guest_client(guest): + client = APIClient() + client.force_authenticate(user=guest) + return client + + +def _make_issue(name, project, workspace, author): + """Create an issue with a deterministic ``created_by``. + + ``BaseModel.save`` auto-sets ``created_by`` from the current request user + (None/anonymous under tests), so a ``created_by=`` kwarg to ``create`` is + overwritten. Passing ``created_by_id`` to ``save`` sets it explicitly. + """ + issue = Issue(name=name, project=project, workspace=workspace) + issue.save(created_by_id=author.id) + return issue + + +@pytest.fixture +def own_issue(db, workspace, project, guest): + """An issue authored by the guest.""" + return _make_issue("Guest's own issue", project, workspace, guest) + + +@pytest.fixture +def foreign_issue(db, workspace, project, create_user): + """An issue authored by someone other than the guest.""" + return _make_issue("Someone else's issue", project, workspace, create_user) + + +@pytest.mark.contract +class TestIssueListGuestScope: + """A restricted guest must only get back issues they authored.""" + + @pytest.mark.django_db + def test_guest_cannot_read_foreign_issue( + self, guest_client, workspace, project, own_issue, foreign_issue + ): + url = LIST_URL.format(slug=workspace.slug, project_id=project.id) + response = guest_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"}) + + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + returned_ids = {str(row["id"]) for row in response.data} + assert str(own_issue.id) in returned_ids + assert str(foreign_issue.id) not in returned_ids, ( + f"Guest read a foreign issue: {response.data!r}" + ) + + @pytest.mark.django_db + def test_project_member_reads_all_requested_issues( + self, session_client, workspace, project, own_issue, foreign_issue + ): + """Positive control: a full member (owner) still gets every requested issue.""" + url = LIST_URL.format(slug=workspace.slug, project_id=project.id) + response = session_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"}) + + assert response.status_code == status.HTTP_200_OK + returned_ids = {str(row["id"]) for row in response.data} + assert {str(own_issue.id), str(foreign_issue.id)} <= returned_ids + + @pytest.mark.django_db + def test_guest_with_view_all_reads_all_requested_issues( + self, guest_client, workspace, project, own_issue, foreign_issue + ): + """When guest_view_all_features is enabled, the guest sees all requested issues.""" + project.guest_view_all_features = True + project.save(update_fields=["guest_view_all_features"]) + + url = LIST_URL.format(slug=workspace.slug, project_id=project.id) + response = guest_client.get(url, {"issues": f"{own_issue.id},{foreign_issue.id}"}) + + assert response.status_code == status.HTTP_200_OK + returned_ids = {str(row["id"]) for row in response.data} + assert {str(own_issue.id), str(foreign_issue.id)} <= returned_ids From 2f6afb0b3c497a0d2224323c08441edd8f9ba2ef Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Ketireddy Date: Thu, 16 Jul 2026 16:01:05 +0530 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/api/plane/app/views/issue/base.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index d367699f536..3b8b05a6a2f 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -95,17 +95,14 @@ def get(self, request, slug, project_id): # Restrict guests without full feature access to issues they created, # mirroring IssueViewSet.list. - project = Project.objects.get(pk=project_id, workspace__slug=slug) - if ( - ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - member=request.user, - role=5, - is_active=True, - ).exists() - and not project.guest_view_all_features - ): + if ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + project__guest_view_all_features=False, + ).exists(): queryset = queryset.filter(created_by=request.user) # Apply filtering from filterset