mirror of
https://github.com/makeplane/plane.git
synced 2026-07-09 20:10:06 +02:00
[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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user