[WEB-8068] fix: scope workspace cycles/modules listing to project membership

WorkspaceCyclesEndpoint and WorkspaceModulesEndpoint are guarded only by
WorkspaceViewerPermission (any active workspace member) and filtered by
workspace__slug alone, letting any workspace member enumerate cycle/module
metadata (names, dates, issue counts) of private projects they are not a
member of (GHSA-wcc5-qgfr-8g9c).

Restrict both querysets to projects the requesting user is an active member
of, mirroring WorkspaceStatesEndpoint / WorkspaceLabelsEndpoint:
  project__project_projectmember__member=request.user
  project__project_projectmember__is_active=True
  project__archived_at__isnull=True
Add .distinct() to the Module query (the member join is to-many; Cycle already
had it).

Contract regression tests cover hidden cycles/modules for a non-project member,
the positive project-member path, and no row duplication; fail-before verified.

Co-authored-by: Plane AI <noreply@plane.so>
This commit is contained in:
Manish Gupta
2026-07-08 14:19:17 +05:30
parent 4fc79a2d7e
commit f59ffbf698
3 changed files with 157 additions and 2 deletions

View File

@@ -21,7 +21,12 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
def get(self, request, slug):
cycles = (
Cycle.objects.filter(workspace__slug=slug)
Cycle.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")

View File

@@ -21,7 +21,12 @@ class WorkspaceModulesEndpoint(BaseAPIView):
def get(self, request, slug):
modules = (
Module.objects.filter(workspace__slug=slug)
Module.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("lead")
@@ -105,6 +110,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
serializer = ModuleSerializer(modules, many=True).data

View File

@@ -0,0 +1,144 @@
# 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 workspace-wide cycles/modules project-scoping.
Regression coverage for GHSA-wcc5-qgfr-8g9c (WEB-8068). ``WorkspaceCyclesEndpoint``
and ``WorkspaceModulesEndpoint`` are guarded only by ``WorkspaceViewerPermission``
(any active workspace member) and previously filtered by ``workspace__slug`` alone.
That let any workspace member enumerate cycle/module metadata (names, dates, issue
counts) for private projects they were not a member of.
The fix restricts both querysets to projects the requesting user is an active
member of, mirroring ``WorkspaceStatesEndpoint`` / ``WorkspaceLabelsEndpoint``.
"""
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import (
Cycle,
Module,
Project,
ProjectMember,
User,
WorkspaceMember,
)
CYCLES_URL = "/api/workspaces/{slug}/cycles/"
MODULES_URL = "/api/workspaces/{slug}/modules/"
@pytest.fixture
def project(db, workspace, create_user):
"""A project in the fixture workspace; ``create_user`` is an active member."""
project = Project.objects.create(
name="Private Project",
identifier="PP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project, member=create_user, workspace=workspace, role=20
)
return project
@pytest.fixture
def cycle(db, workspace, project, create_user):
return Cycle.objects.create(
name="Private Cycle",
project=project,
workspace=workspace,
owned_by=create_user,
)
@pytest.fixture
def module(db, workspace, project):
return Module.objects.create(
name="Private Module",
project=project,
workspace=workspace,
)
@pytest.fixture
def outsider_client(db, workspace):
"""Session client for a workspace member who is NOT in ``project``."""
unique_id = uuid4().hex[:8]
outsider = User.objects.create(
email=f"outsider-{unique_id}@plane.so",
username=f"outsider_{unique_id}",
first_name="Outsider",
last_name="User",
)
outsider.set_password("test-password")
outsider.save()
WorkspaceMember.objects.create(workspace=workspace, member=outsider, role=15)
client = APIClient()
client.force_authenticate(user=outsider)
return client
@pytest.mark.contract
class TestWorkspaceCyclesModulesProjectScope:
"""A workspace member must not see cycles/modules of projects they aren't in."""
@pytest.mark.django_db
def test_cycles_hidden_from_non_project_member(self, outsider_client, workspace, cycle):
response = outsider_client.get(CYCLES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert response.data == [], f"Leaked cycle metadata: {response.data!r}"
@pytest.mark.django_db
def test_modules_hidden_from_non_project_member(self, outsider_client, workspace, module):
response = outsider_client.get(MODULES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK, (
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
)
assert response.data == [], f"Leaked module metadata: {response.data!r}"
@pytest.mark.django_db
def test_cycles_visible_to_project_member(self, session_client, workspace, cycle):
"""Positive control: an active project member still sees the cycle."""
response = session_client.get(CYCLES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK
ids = {str(row["id"]) for row in response.data}
assert str(cycle.id) in ids, f"Expected cycle {cycle.id} in {response.data!r}"
@pytest.mark.django_db
def test_modules_visible_to_project_member(self, session_client, workspace, module):
"""Positive control: an active project member still sees the module."""
response = session_client.get(MODULES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK
ids = {str(row["id"]) for row in response.data}
assert str(module.id) in ids, f"Expected module {module.id} in {response.data!r}"
@pytest.mark.django_db
def test_modules_not_duplicated_for_project_member(
self, session_client, workspace, project, module
):
"""The project-member join must not duplicate a module row (.distinct())."""
# Add a second active member to the project so the reverse
# project_projectmember join fans out to more than one row.
other = User.objects.create(
email=f"peer-{uuid4().hex[:8]}@plane.so",
username=f"peer_{uuid4().hex[:8]}",
)
other.set_password("test-password")
other.save()
ProjectMember.objects.create(
project=project, member=other, workspace=workspace, role=15
)
response = session_client.get(MODULES_URL.format(slug=workspace.slug))
assert response.status_code == status.HTTP_200_OK
module_rows = [row for row in response.data if str(row["id"]) == str(module.id)]
assert len(module_rows) == 1, f"Module row duplicated: {response.data!r}"