[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:
Manish Gupta
2026-07-27 12:07:00 +05:30
parent a8e53b6ac7
commit 834ee3cc17
2 changed files with 118 additions and 0 deletions

View File

@@ -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

View File

@@ -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}"
)