mirror of
https://github.com/makeplane/plane.git
synced 2026-09-01 19:48:42 +02:00
[WEB-8400] fix(security): scope DeployBoardViewSet queryset to the URL workspace/project (GHSA-h4w5-vhxc-265g)
DeployBoardViewSet defines only list/create; the routed retrieve/partial_update/ destroy fall through to DRF's ModelViewSet defaults, which resolve the object via get_object() -> get_queryset(). The base get_queryset returns DeployBoard.objects.all() (every workspace), and ProjectMemberPermission only checks the URL slug/project_id — nothing binds the object to that scope. So any authenticated user could supply their own workspace+project in the URL and a victim board's pk (disclosed unauth via the public settings endpoint) to read, modify, or hard-delete any workspace's published board. Override get_queryset to scope to the URL workspace__slug + project so a foreign pk 404s. Adds 4 contract tests (cross-workspace retrieve/destroy/patch blocked, own board retrievable); fail-before verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -537,6 +537,22 @@ class DeployBoardViewSet(BaseViewSet):
|
||||
serializer_class = DeployBoardSerializer
|
||||
model = DeployBoard
|
||||
|
||||
def get_queryset(self):
|
||||
# SECURITY: the routed retrieve/partial_update/destroy actions are not
|
||||
# defined on this class and fall through to DRF's ModelViewSet defaults,
|
||||
# which resolve the object via get_object() -> get_queryset(). The base
|
||||
# get_queryset returns DeployBoard.objects.all() (every workspace), and
|
||||
# ProjectMemberPermission only checks the URL slug/project_id — nothing
|
||||
# binds the object to that scope. Scope the queryset to the URL workspace
|
||||
# + project so a foreign pk 404s instead of being read/modified/deleted
|
||||
# cross-workspace (GHSA-h4w5-vhxc-265g).
|
||||
return DeployBoard.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
entity_name="project",
|
||||
entity_identifier=self.kwargs.get("project_id"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
project_deploy_board = DeployBoard.objects.filter(
|
||||
entity_name="project", entity_identifier=project_id, workspace__slug=slug
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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 DeployBoardViewSet object scoping.
|
||||
|
||||
Regression coverage for GHSA-h4w5-vhxc-265g. ``DeployBoardViewSet`` defines only
|
||||
``list``/``create``; the routed ``retrieve``/``partial_update``/``destroy`` fall
|
||||
through to DRF's ``ModelViewSet`` defaults, which resolve the object via
|
||||
``get_object()`` -> ``get_queryset()``. The base ``get_queryset`` returns
|
||||
``DeployBoard.objects.all()`` (every workspace), and ``ProjectMemberPermission``
|
||||
only checks the URL slug/project_id. So any authenticated user could supply their
|
||||
own workspace+project in the URL and a victim board's pk to read/modify/hard-delete
|
||||
another workspace's published board.
|
||||
|
||||
The fix scopes ``get_queryset`` to the URL workspace + project, so a foreign pk
|
||||
404s.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import DeployBoard, Project, ProjectMember, User, Workspace, WorkspaceMember
|
||||
|
||||
|
||||
def _board_url(slug, project_id, pk):
|
||||
return f"/api/workspaces/{slug}/projects/{project_id}/project-deploy-boards/{pk}/"
|
||||
|
||||
|
||||
def _board_for(workspace, project):
|
||||
return DeployBoard.objects.create(
|
||||
workspace=workspace, project=project, entity_name="project", entity_identifier=project.id
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_a(db, workspace, create_user):
|
||||
"""The attacker's own project; create_user (session_client) is a member."""
|
||||
project = Project.objects.create(
|
||||
name="Attacker Project", identifier="ATK", workspace=workspace, created_by=create_user
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=create_user, workspace=workspace, role=20, is_active=True)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def own_board(db, workspace, project_a):
|
||||
return _board_for(workspace, project_a)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_board(db):
|
||||
"""A published board in a DIFFERENT workspace the caller has no relation to."""
|
||||
unique = uuid4().hex[:8]
|
||||
owner = User.objects.create(email=f"victim-{unique}@plane.so", username=f"victim_{unique}")
|
||||
owner.set_password("test-password")
|
||||
owner.save()
|
||||
ws = Workspace.objects.create(name="Victim WS", slug=f"victim-{unique}", owner=owner)
|
||||
WorkspaceMember.objects.create(workspace=ws, member=owner, role=20, is_active=True)
|
||||
project = Project.objects.create(name="Victim Project", identifier="VIC", workspace=ws, created_by=owner)
|
||||
ProjectMember.objects.create(project=project, member=owner, workspace=ws, role=20, is_active=True)
|
||||
return _board_for(ws, project)
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.django_db
|
||||
class TestDeployBoardCrossWorkspaceScope:
|
||||
"""A caller must not reach another workspace's board via their own URL scope."""
|
||||
|
||||
def test_cannot_retrieve_foreign_board(self, session_client, workspace, project_a, victim_board):
|
||||
response = session_client.get(_board_url(workspace.slug, project_a.id, victim_board.id))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
def test_cannot_destroy_foreign_board(self, session_client, workspace, project_a, victim_board):
|
||||
response = session_client.delete(_board_url(workspace.slug, project_a.id, victim_board.id))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert DeployBoard.objects.filter(pk=victim_board.id).exists(), "Foreign board was deleted"
|
||||
|
||||
def test_cannot_patch_foreign_board(self, session_client, workspace, project_a, victim_board):
|
||||
response = session_client.patch(
|
||||
_board_url(workspace.slug, project_a.id, victim_board.id),
|
||||
{"is_comments_enabled": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
victim_board.refresh_from_db()
|
||||
assert victim_board.is_comments_enabled is False
|
||||
|
||||
def test_can_retrieve_own_board(self, session_client, workspace, project_a, own_board):
|
||||
"""Positive control: a project member may retrieve their own board."""
|
||||
response = session_client.get(_board_url(workspace.slug, project_a.id, own_board.id))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user