diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py index cff5ad086f7..16dd5891e1b 100644 --- a/apps/api/plane/space/views/intake.py +++ b/apps/api/plane/space/views/intake.py @@ -113,6 +113,14 @@ def create(self, request, anchor, intake_id): status=status.HTTP_400_BAD_REQUEST, ) + # Ensure the intake belongs to this board before writing + # (GHSA-vqr2-wx56-gmq4: caller-supplied intake_id must be bound to the anchor). + if str(intake_id) != str(project_deploy_board.intake_id): + return Response( + {"error": "Intake does not belong to this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not request.data.get("issue", {}).get("name", False): return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 9e2187466aa..3f44f7ccb47 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -70,6 +70,33 @@ from plane.utils.issue_filters import issue_filters +# GHSA-vqr2-wx56-gmq4: public Spaces board writes must bind caller-supplied +# object ids to the board's project + workspace. Shared by every create() so the +# check can't drift between endpoints or be forgotten on a new one. +def _issue_in_board_scope(issue_id, project_deploy_board): + """A board-visible issue in the board's project + workspace. + + Uses ``issue_objects`` (excludes draft/archived/triage) to match exactly + what the public board displays via ProjectIssuesPublicEndpoint / + IssueRetrievePublicEndpoint — you can only write on what the board shows. + """ + return Issue.issue_objects.filter( + id=issue_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + ).exists() + + +def _comment_in_board_scope(comment_id, project_deploy_board): + """A public (EXTERNAL) comment in the board's project + workspace.""" + return IssueComment.objects.filter( + id=comment_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + access="EXTERNAL", + ).exists() + + class ProjectIssuesPublicEndpoint(BaseAPIView): permission_classes = [AllowAny] @@ -233,6 +260,7 @@ def get_queryset(self): super() .get_queryset() .filter(workspace_id=project_deploy_board.workspace_id) + .filter(project_id=project_deploy_board.project_id) .filter(issue_id=self.kwargs.get("issue_id")) .filter(access="EXTERNAL") .select_related("project") @@ -263,6 +291,10 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) + # Bind the caller-supplied issue_id to this board (GHSA-vqr2-wx56-gmq4). + if not _issue_in_board_scope(issue_id, project_deploy_board): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -372,6 +404,10 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) + # Bind the caller-supplied issue_id to this board (GHSA-vqr2-wx56-gmq4). + if not _issue_in_board_scope(issue_id, project_deploy_board): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = IssueReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -457,6 +493,10 @@ def create(self, request, anchor, comment_id): status=status.HTTP_400_BAD_REQUEST, ) + # Bind the caller-supplied comment_id to this board (GHSA-vqr2-wx56-gmq4). + if not _comment_in_board_scope(comment_id, project_deploy_board): + return Response({"error": "Comment not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = CommentReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -542,6 +582,17 @@ def get_queryset(self): def create(self, request, anchor, issue_id): project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + + if not project_deploy_board.is_votes_enabled: + return Response( + {"error": "Votes are not enabled for this project board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Bind the caller-supplied issue_id to this board (GHSA-vqr2-wx56-gmq4). + if not _issue_in_board_scope(issue_id, project_deploy_board): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + issue_vote, _ = IssueVote.objects.get_or_create( actor_id=request.user.id, project_id=project_deploy_board.project_id, diff --git a/apps/api/plane/tests/contract/app/test_spaces_board_object_scope_app.py b/apps/api/plane/tests/contract/app/test_spaces_board_object_scope_app.py new file mode 100644 index 00000000000..6baf0f68516 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_spaces_board_object_scope_app.py @@ -0,0 +1,392 @@ +# 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 public Spaces board object-ID scoping. + +Regression coverage for GHSA-vqr2-wx56-gmq4 (WEB-8283). + +The public Spaces board write endpoints (``/api/public/anchor//...``) +resolve the ``DeployBoard`` from the URL ``anchor`` but previously trusted the +caller-supplied ``issue_id`` / ``comment_id`` / ``intake_id`` verbatim, without +verifying the target object belonged to that board's project/workspace. Any +authenticated user could therefore write a comment / reaction / vote onto an +arbitrary issue in any project (cross-tenant), and read EXTERNAL comments from a +different project in the same workspace. + +The fix binds every caller-supplied object id to the board's project + workspace +before writing, and scopes the comment-list read to the board's project. +""" + +from unittest import mock +from uuid import uuid4 + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from plane.db.models import ( + DeployBoard, + Intake, + Issue, + IssueComment, + Project, + ProjectMember, + State, + User, + Workspace, + WorkspaceMember, +) + + +# --------------------------------------------------------------------------- # +# URL helpers +# --------------------------------------------------------------------------- # +def comments_url(anchor, issue_id): + return f"/api/public/anchor/{anchor}/issues/{issue_id}/comments/" + + +def issue_reactions_url(anchor, issue_id): + return f"/api/public/anchor/{anchor}/issues/{issue_id}/reactions/" + + +def comment_reactions_url(anchor, comment_id): + return f"/api/public/anchor/{anchor}/comments/{comment_id}/reactions/" + + +def votes_url(anchor, issue_id): + return f"/api/public/anchor/{anchor}/issues/{issue_id}/votes/" + + +def intake_issues_url(anchor, intake_id): + return f"/api/public/anchor/{anchor}/intakes/{intake_id}/intake-issues/" + + +# --------------------------------------------------------------------------- # +# Fixtures +# --------------------------------------------------------------------------- # +@pytest.fixture(autouse=True) +def _no_activity(db): + """Stub the deferred activity task so writes never touch the broker.""" + with ( + mock.patch("plane.space.views.issue.issue_activity"), + mock.patch("plane.space.views.intake.issue_activity"), + ): + yield + + +@pytest.fixture +def board(db, workspace, create_user): + """A published project board (comments/reactions/votes + intake enabled). + + ``create_user`` (session_client) is an active member of this project. + """ + project = Project.objects.create( + name="Board Project", identifier="BRD", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create( + project=project, member=create_user, workspace=workspace, role=20, is_active=True + ) + state = State.objects.create( + name="Todo", project=project, workspace=workspace, group="backlog", default=True + ) + issue = Issue.objects.create( + name="Board Issue", workspace=workspace, project=project, state=state, created_by=create_user + ) + comment = IssueComment.objects.create( + issue=issue, + project=project, + workspace=workspace, + comment_html="

board external comment

", + access="EXTERNAL", + created_by=create_user, + actor=create_user, + ) + intake = Intake.objects.create(name="Board Intake", project=project, workspace=workspace) + deploy_board = DeployBoard.objects.create( + entity_name="project", + entity_identifier=project.id, + project=project, + workspace=workspace, + is_comments_enabled=True, + is_reactions_enabled=True, + is_votes_enabled=True, + intake=intake, + ) + return { + "project": project, + "issue": issue, + "comment": comment, + "intake": intake, + "anchor": deploy_board.anchor, + } + + +@pytest.fixture +def victim(db, workspace, create_user): + """A *different* project in the SAME workspace, not published on ``board``.""" + project = Project.objects.create( + name="Victim Project", identifier="VIC", workspace=workspace, created_by=create_user + ) + state = State.objects.create( + name="Todo", project=project, workspace=workspace, group="backlog", default=True + ) + issue = Issue.objects.create( + name="Victim Issue", workspace=workspace, project=project, state=state, created_by=create_user + ) + comment = IssueComment.objects.create( + issue=issue, + project=project, + workspace=workspace, + comment_html="

secret external comment

", + access="EXTERNAL", + created_by=create_user, + actor=create_user, + ) + intake = Intake.objects.create(name="Victim Intake", project=project, workspace=workspace) + return {"project": project, "issue": issue, "comment": comment, "intake": intake} + + +@pytest.fixture +def victim_other_ws(db, create_user): + """A project in a DIFFERENT workspace — exercises the workspace_id binding + (true cross-tenant, matching the advisory's stated impact).""" + uid = uuid4().hex[:8] + owner = User.objects.create(email=f"victim-owner-{uid}@plane.so", username=f"victim_owner_{uid}") + owner.set_password("test-password") + owner.save() + other_ws = Workspace.objects.create(name="Other WS", owner=owner, slug=f"other-ws-{uid}") + WorkspaceMember.objects.create(workspace=other_ws, member=owner, role=20) + project = Project.objects.create( + name="Other WS Project", identifier="OWP", workspace=other_ws, created_by=owner + ) + state = State.objects.create( + name="Todo", project=project, workspace=other_ws, group="backlog", default=True + ) + issue = Issue.objects.create( + name="Other WS Issue", workspace=other_ws, project=project, state=state, created_by=owner + ) + return {"workspace": other_ws, "project": project, "issue": issue} + + +@pytest.fixture +def board_votes_disabled(db, workspace, create_user): + """A published board with voting DISABLED, for the is_votes_enabled gate.""" + project = Project.objects.create( + name="No-Vote Project", identifier="NVP", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create( + project=project, member=create_user, workspace=workspace, role=20, is_active=True + ) + state = State.objects.create( + name="Todo", project=project, workspace=workspace, group="backlog", default=True + ) + issue = Issue.objects.create( + name="No-Vote Issue", workspace=workspace, project=project, state=state, created_by=create_user + ) + deploy_board = DeployBoard.objects.create( + entity_name="project", + entity_identifier=project.id, + project=project, + workspace=workspace, + is_comments_enabled=True, + is_reactions_enabled=True, + is_votes_enabled=False, + intake=None, + ) + return {"project": project, "issue": issue, "anchor": deploy_board.anchor} + + +@pytest.fixture +def attacker_client(db, workspace): + """An authenticated user who is NOT a member of the victim project.""" + uid = uuid4().hex[:8] + user = User.objects.create(email=f"attacker-{uid}@plane.so", username=f"attacker_{uid}") + user.set_password("test-password") + user.save() + WorkspaceMember.objects.create(workspace=workspace, member=user, role=15) + client = APIClient() + client.force_authenticate(user=user) + return client + + +# --------------------------------------------------------------------------- # +# Cross-tenant WRITE — must be rejected (404 for issue/comment binding, +# 400 for the intake binding mismatch, per each endpoint's contract below) +# --------------------------------------------------------------------------- # +@pytest.mark.contract +class TestSpacesBoardObjectScope: + @pytest.mark.django_db + def test_cannot_comment_on_issue_outside_board_project(self, attacker_client, board, victim): + response = attacker_client.post( + comments_url(board["anchor"], victim["issue"].id), + {"comment_html": "

injected

"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueComment.objects.filter( + issue_id=victim["issue"].id, comment_html="

injected

" + ).exists() + + @pytest.mark.django_db + def test_cannot_react_to_issue_outside_board_project(self, attacker_client, board, victim): + response = attacker_client.post( + issue_reactions_url(board["anchor"], victim["issue"].id), + {"reaction": "smile"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_cannot_vote_on_issue_outside_board_project(self, attacker_client, board, victim): + response = attacker_client.post( + votes_url(board["anchor"], victim["issue"].id), + {"vote": 1}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_cannot_react_to_comment_outside_board_project(self, attacker_client, board, victim): + response = attacker_client.post( + comment_reactions_url(board["anchor"], victim["comment"].id), + {"reaction": "smile"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_cannot_create_intake_issue_with_foreign_intake(self, attacker_client, board, victim): + response = attacker_client.post( + intake_issues_url(board["anchor"], victim["intake"].id), + {"issue": {"name": "injected intake"}}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_cannot_comment_on_issue_in_other_workspace(self, attacker_client, board, victim_other_ws): + """The guard binds on workspace_id too — a board's anchor cannot reach an + issue in a different workspace (true cross-tenant vector).""" + response = attacker_client.post( + comments_url(board["anchor"], victim_other_ws["issue"].id), + {"comment_html": "

injected cross-ws

"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueComment.objects.filter( + issue_id=victim_other_ws["issue"].id, comment_html="

injected cross-ws

" + ).exists() + + # ----------------------------------------------------------------------- # + # Feature gate — vote create must honor is_votes_enabled (parity fix) + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_cannot_vote_when_votes_disabled(self, session_client, board_votes_disabled): + response = session_client.post( + votes_url(board_votes_disabled["anchor"], board_votes_disabled["issue"].id), + {"vote": 1}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + # ----------------------------------------------------------------------- # + # Cross-project READ leak — comment list must not surface other projects + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_comment_list_does_not_leak_cross_project(self, attacker_client, board, victim): + response = attacker_client.get(comments_url(board["anchor"], victim["issue"].id)) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + results = response.data["results"] if isinstance(response.data, dict) else response.data + returned_ids = {str(c["id"]) for c in results} + assert str(victim["comment"].id) not in returned_ids, ( + "Victim project's EXTERNAL comment leaked through another board's anchor" + ) + + @pytest.mark.django_db + def test_comment_list_returns_own_project_comments(self, session_client, board): + """Positive control: the board's own EXTERNAL comments are still listed + (guards against an over-broad project_id filter returning nothing).""" + response = session_client.get(comments_url(board["anchor"], board["issue"].id)) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + results = response.data["results"] if isinstance(response.data, dict) else response.data + returned_ids = {str(c["id"]) for c in results} + assert str(board["comment"].id) in returned_ids, ( + "Board's own EXTERNAL comment was wrongly filtered out" + ) + + # ----------------------------------------------------------------------- # + # Positive controls — legitimate writes on the board's own issue still work + # ----------------------------------------------------------------------- # + @pytest.mark.django_db + def test_can_comment_on_issue_in_board_project(self, session_client, board): + response = session_client.post( + comments_url(board["anchor"], board["issue"].id), + {"comment_html": "

legit

"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_vote_on_issue_in_board_project(self, session_client, board): + response = session_client.post( + votes_url(board["anchor"], board["issue"].id), + {"vote": 1}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_react_to_issue_in_board_project(self, session_client, board): + response = session_client.post( + issue_reactions_url(board["anchor"], board["issue"].id), + {"reaction": "smile"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_react_to_comment_in_board_project(self, session_client, board): + response = session_client.post( + comment_reactions_url(board["anchor"], board["comment"].id), + {"reaction": "smile"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + + @pytest.mark.django_db + def test_can_create_intake_issue_with_own_intake(self, session_client, board): + response = session_client.post( + intake_issues_url(board["anchor"], board["intake"].id), + {"issue": {"name": "legit intake issue"}}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + )