From 6a7fd8706763f069edc2c5db1688084037c45265 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Mon, 20 Jul 2026 09:57:46 +0530 Subject: [PATCH 1/3] [WEB-8283] fix: bind Spaces board object IDs to the anchor's project (GHSA-vqr2-wx56-gmq4) The public Spaces board endpoints resolved the DeployBoard from the URL anchor but trusted the caller-supplied issue_id/comment_id/intake_id verbatim, without verifying the object belonged to that board's project/workspace. Any authenticated user could write comments, reactions and votes onto arbitrary issues cross-tenant, and read EXTERNAL comments from a different project in the same workspace. Bind every caller-supplied object id to the board's project + workspace before writing: - comment / issue-reaction / vote create: require the issue to exist in the board's project via Issue.issue_objects (excludes draft/archived/ triage), else 404. - comment-reaction create: require the comment to exist in the board's project with access="EXTERNAL", else 404. - intake create: require the URL intake_id to match the board's intake, else 400. - comment list read: scope the queryset to the board's project_id. Also add the missing is_votes_enabled gate on vote create for parity with comment/reaction create (pre-existing gap in the same method). Adds contract regression tests (fail-before verified): cross-tenant writes and the cross-project comment read now rejected, with positive controls confirming legitimate board writes/reads still succeed. Co-authored-by: Plane AI --- apps/api/plane/space/views/intake.py | 8 + apps/api/plane/space/views/issue.py | 45 +++ .../app/test_spaces_board_object_scope_app.py | 310 ++++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 apps/api/plane/tests/contract/app/test_spaces_board_object_scope_app.py 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..58f3e357a33 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -233,6 +233,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 +264,15 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) + # Ensure the issue belongs to this board's project before writing + # (GHSA-vqr2-wx56-gmq4: caller-supplied issue_id must be bound to the anchor). + if not Issue.issue_objects.filter( + id=issue_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + ).exists(): + 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 +382,15 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) + # Ensure the issue belongs to this board's project before writing + # (GHSA-vqr2-wx56-gmq4: caller-supplied issue_id must be bound to the anchor). + if not Issue.issue_objects.filter( + id=issue_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + ).exists(): + 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 +476,16 @@ def create(self, request, anchor, comment_id): status=status.HTTP_400_BAD_REQUEST, ) + # Ensure the comment belongs to this board's project before writing + # (GHSA-vqr2-wx56-gmq4: caller-supplied comment_id must be bound to the anchor). + if not IssueComment.objects.filter( + id=comment_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + access="EXTERNAL", + ).exists(): + 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 +571,22 @@ 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, + ) + + # Ensure the issue belongs to this board's project before writing + # (GHSA-vqr2-wx56-gmq4: caller-supplied issue_id must be bound to the anchor). + if not Issue.issue_objects.filter( + id=issue_id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, + ).exists(): + 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..85d2b4b43cc --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_spaces_board_object_scope_app.py @@ -0,0 +1,310 @@ +# 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, + 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 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) +# --------------------------------------------------------------------------- # +@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}" + ) + + # ----------------------------------------------------------------------- # + # 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}" + ) From 24faac6cdf3b010e1eff504c7ec00cdd584e3711 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Mon, 20 Jul 2026 10:15:51 +0530 Subject: [PATCH 2/3] =?UTF-8?q?[WEB-8283]=20test:=20address=20Copilot=20re?= =?UTF-8?q?view=20=E2=80=94=20cross-workspace=20+=20votes-disabled=20cover?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cross-workspace write test (issue in a different workspace) to exercise the workspace_id binding, matching the advisory's cross-tenant impact (previously only same-workspace/different-project was covered). - Add a regression test for the new is_votes_enabled gate on vote create (votes-disabled board → 400), preventing the pre-existing gap from reappearing. - Clarify the section header comment: cross-tenant writes return 404 for issue/comment binding, 400 for the intake binding mismatch. Co-authored-by: Plane AI --- .../app/test_spaces_board_object_scope_app.py | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) 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 index 85d2b4b43cc..6baf0f68516 100644 --- 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 @@ -34,6 +34,7 @@ ProjectMember, State, User, + Workspace, WorkspaceMember, ) @@ -146,6 +147,56 @@ def victim(db, workspace, create_user): 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.""" @@ -160,7 +211,8 @@ def attacker_client(db, workspace): # --------------------------------------------------------------------------- # -# Cross-tenant WRITE — must be rejected (404) +# 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: @@ -222,6 +274,36 @@ def test_cannot_create_intake_issue_with_foreign_intake(self, attacker_client, b 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 # ----------------------------------------------------------------------- # From 06de0d6bf900a8aa5d1fb641d340969ea18a7057 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Mon, 20 Jul 2026 10:18:24 +0530 Subject: [PATCH 3/3] [WEB-8283] refactor: extract board-scope guards into shared helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review: the identical "object belongs to the board's project+workspace" existence check was duplicated across four create() methods (in four different ViewSets). Extract two module-level helpers — _issue_in_board_scope and _comment_in_board_scope — so the check is a single source of truth and cannot drift between endpoints or be forgotten on a new one (the exact class of bug this PR fixes). Behavior-preserving; 14 contract tests still green. Co-authored-by: Plane AI --- apps/api/plane/space/views/issue.py | 64 ++++++++++++++++------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 58f3e357a33..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] @@ -264,13 +291,8 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) - # Ensure the issue belongs to this board's project before writing - # (GHSA-vqr2-wx56-gmq4: caller-supplied issue_id must be bound to the anchor). - if not Issue.issue_objects.filter( - id=issue_id, - project_id=project_deploy_board.project_id, - workspace_id=project_deploy_board.workspace_id, - ).exists(): + # 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) @@ -382,13 +404,8 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) - # Ensure the issue belongs to this board's project before writing - # (GHSA-vqr2-wx56-gmq4: caller-supplied issue_id must be bound to the anchor). - if not Issue.issue_objects.filter( - id=issue_id, - project_id=project_deploy_board.project_id, - workspace_id=project_deploy_board.workspace_id, - ).exists(): + # 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) @@ -476,14 +493,8 @@ def create(self, request, anchor, comment_id): status=status.HTTP_400_BAD_REQUEST, ) - # Ensure the comment belongs to this board's project before writing - # (GHSA-vqr2-wx56-gmq4: caller-supplied comment_id must be bound to the anchor). - if not IssueComment.objects.filter( - id=comment_id, - project_id=project_deploy_board.project_id, - workspace_id=project_deploy_board.workspace_id, - access="EXTERNAL", - ).exists(): + # 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) @@ -578,13 +589,8 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) - # Ensure the issue belongs to this board's project before writing - # (GHSA-vqr2-wx56-gmq4: caller-supplied issue_id must be bound to the anchor). - if not Issue.issue_objects.filter( - id=issue_id, - project_id=project_deploy_board.project_id, - workspace_id=project_deploy_board.workspace_id, - ).exists(): + # 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(