diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py index 5a07bdefe5d..5f75f0b99db 100644 --- a/apps/api/plane/app/permissions/project.py +++ b/apps/api/plane/app/permissions/project.py @@ -66,12 +66,16 @@ def has_permission(self, request, view): project_id=view.project_id, is_active=True, ).exists() - ## Only workspace owners or admins can create the projects + # Scope POST to the URL project, as the other two branches already do. + # A workspace-only check let any member create sub-resources in a project + # they do not belong to — including a deploy board, which returns the + # public anchor and exposes a Secret project to anonymous callers. if request.method == "POST": - return WorkspaceMember.objects.filter( + return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + project_id=view.project_id, is_active=True, ).exists() diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index ec55dcb9df5..d0186206ffb 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -561,6 +561,12 @@ def create(self, request, slug, project_id): }, ) + # project_id comes from the URL and was never checked against slug, so a + # caller could aim their own workspace at another tenant's project id. + # Defence in depth: the permission class now binds both. + if not Project.objects.filter(pk=project_id, workspace__slug=slug).exists(): + return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND) + project_deploy_board, _ = DeployBoard.objects.get_or_create( entity_name="project", entity_identifier=project_id, project_id=project_id ) diff --git a/apps/api/plane/tests/contract/app/test_deploy_board_project_scope_app.py b/apps/api/plane/tests/contract/app/test_deploy_board_project_scope_app.py index 0dbfbbcb807..d7c983fe4b3 100644 --- a/apps/api/plane/tests/contract/app/test_deploy_board_project_scope_app.py +++ b/apps/api/plane/tests/contract/app/test_deploy_board_project_scope_app.py @@ -20,7 +20,7 @@ from rest_framework import status from rest_framework.test import APIClient -from plane.db.models import Project, ProjectMember, User, WorkspaceMember +from plane.db.models import DeployBoard, Project, ProjectMember, User, Workspace, WorkspaceMember def deploy_board_url(slug, project_id): @@ -89,3 +89,105 @@ def test_project_member_can_read_deploy_board(self, session_client, workspace, p assert response.status_code == status.HTTP_200_OK, ( f"Got {response.status_code}: {getattr(response, 'data', None)!r}" ) + + +@pytest.fixture +def secret_project(db, workspace, create_user): + """A Secret (network=0) project that ``create_user`` administers.""" + project = Project.objects.create( + name="Secret Project", + identifier="SEC", + workspace=workspace, + network=0, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, member=create_user, workspace=workspace, role=20 + ) + return project + + +@pytest.fixture +def foreign_project(db, create_user): + """A project in a DIFFERENT workspace that nobody here belongs to.""" + unique_id = uuid4().hex[:8] + owner = User.objects.create( + email=f"victim-{unique_id}@plane.so", username=f"victim_{unique_id}" + ) + other_ws = Workspace.objects.create( + name="Victim Workspace", slug=f"victim-{unique_id}", owner=owner + ) + WorkspaceMember.objects.create(workspace=other_ws, member=owner, role=20) + return Project.objects.create( + name="Victim Project", + identifier="VIC", + workspace=other_ws, + network=0, + created_by=owner, + ) + + +@pytest.mark.contract +class TestDeployBoardCreateProjectScope: + """POST is the publish action: it returns the public anchor. + + ``ProjectMemberPermission``'s POST branch previously checked workspace + membership only, so a workspace member who was not in the project could + publish it and receive the anchor — which Space serves to anonymous callers. + """ + + @pytest.mark.django_db + def test_non_project_member_cannot_publish(self, outsider_client, workspace, secret_project): + response = outsider_client.post( + deploy_board_url(workspace.slug, secret_project.id), {}, format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_denied_publish_leaks_no_anchor_and_creates_no_board( + self, outsider_client, workspace, secret_project + ): + """The response must not carry an anchor, and no board may be created. + + A 403 that still created the DeployBoard would leave the project + published even though the API refused the caller. + """ + response = outsider_client.post( + deploy_board_url(workspace.slug, secret_project.id), {}, format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "anchor" not in str(getattr(response, "data", "")).lower() + assert not DeployBoard.objects.filter( + entity_name="project", entity_identifier=secret_project.id + ).exists(), "a denied publish must not create a DeployBoard" + + @pytest.mark.django_db + def test_project_member_can_publish(self, session_client, workspace, secret_project): + """Positive control: scoping POST must not break the legitimate publish.""" + response = session_client.post( + deploy_board_url(workspace.slug, secret_project.id), {}, format="json" + ) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert DeployBoard.objects.filter( + entity_name="project", entity_identifier=secret_project.id + ).exists() + + @pytest.mark.django_db + def test_cannot_publish_another_workspaces_project( + self, session_client, workspace, foreign_project + ): + """Own slug + a foreign project id must not publish the victim's project.""" + response = session_client.post( + deploy_board_url(workspace.slug, foreign_project.id), {}, format="json" + ) + assert response.status_code in ( + status.HTTP_403_FORBIDDEN, + status.HTTP_404_NOT_FOUND, + ), f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + assert not DeployBoard.objects.filter( + entity_name="project", entity_identifier=foreign_project.id + ).exists(), "a cross-workspace publish must not create a DeployBoard" diff --git a/apps/api/plane/utils/permissions/project.py b/apps/api/plane/utils/permissions/project.py index 5a07bdefe5d..e04c68022ea 100644 --- a/apps/api/plane/utils/permissions/project.py +++ b/apps/api/plane/utils/permissions/project.py @@ -66,12 +66,16 @@ def has_permission(self, request, view): project_id=view.project_id, is_active=True, ).exists() - ## Only workspace owners or admins can create the projects + # Scope POST to the URL project, as the other two branches already do. + # A workspace-only check would let any member create sub-resources in a + # project they do not belong to. Kept identical to the copy in + # app/permissions/project.py — the two must not drift. if request.method == "POST": - return WorkspaceMember.objects.filter( + return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + project_id=view.project_id, is_active=True, ).exists()