[WEB-8074] fix: scope IssueListEndpoint to guest created_by (#9374)

* [WEB-8074] fix: scope IssueListEndpoint to guest created_by

IssueListEndpoint.get (/workspaces/<slug>/projects/<project_id>/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 <noreply@plane.so>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: Dheeraj Kumar Ketireddy <dheeraj.ketireddy@plane.so>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Manish Gupta
2026-07-16 16:03:25 +05:30
committed by GitHub
parent cfe951c8af
commit af1be50b48
2 changed files with 157 additions and 0 deletions

View File

@@ -93,6 +93,18 @@ class IssueListEndpoint(BaseAPIView):
# 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.
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
queryset = self.filter_queryset(queryset)

View File

@@ -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/<slug>/projects/<project_id>/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