Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/api/plane/app/permissions/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
mguptahub marked this conversation as resolved.
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
project_id=view.project_id,
is_active=True,
).exists()

Expand Down
6 changes: 6 additions & 0 deletions apps/api/plane/app/views/project/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 not found"}, status=status.HTTP_404_NOT_FOUND)
Comment thread
Copilot marked this conversation as resolved.
Outdated

project_deploy_board, _ = DeployBoard.objects.get_or_create(
entity_name="project", entity_identifier=project_id, project_id=project_id
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Loading