mirror of
https://github.com/makeplane/plane.git
synced 2026-08-29 10:08:51 +02:00
[WEB-8283] fix: bind Spaces board object IDs to the anchor's project (#9442)
* [WEB-8283] fix: bind Spaces board object IDs to the anchor's project 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 <noreply@plane.so> * [WEB-8283] test: address Copilot review — cross-workspace + votes-disabled coverage - 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 <noreply@plane.so> * [WEB-8283] refactor: extract board-scope guards into shared helpers 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 <noreply@plane.so> * chore(security): drop advisory identifiers from code comments Explanations kept unchanged; only the IDs are removed. Co-authored-by: Plane AI <noreply@plane.so> * fix(security): use the deploy board's project_id in the reaction-create activity log CommentReactionPublicViewSet.create() logged the activity with str(self.kwargs.get("project_id", None)) — this route's URL only ever supplies anchor and comment_id, never project_id, so every comment reaction created on a public board logged project_id="None", silently corrupting the activity/audit trail. destroy() on the same viewset already resolves the correct project_id from the deploy board; create() now does the same. Co-authored-by: Plane AI <noreply@plane.so> * [WEB-8283] fix: apply board-scope guards to comment/reaction read, update and delete paths The board/issue/external-comment scoping added by this PR's create() methods was never applied to the list, update and delete paths built on the same models. A caller could read reactions on an INTERNAL (non-public) comment through the public reaction list, or reach a comment or reaction they authored through a board it doesn't actually belong to via partial_update() or destroy(), since those methods looked up objects by pk/actor only. Bind IssueCommentPublicViewSet.partial_update()/destroy() to the board's project, workspace, issue_id and EXTERNAL access; bind IssueReactionPublicViewSet.destroy() to the board's project (previously only workspace-scoped); and bind CommentReactionPublicViewSet.get_queryset()/ destroy() to EXTERNAL comments only. Add regression coverage for each gap, plus positive controls confirming legitimate reads/writes on the board's own objects still work. Co-authored-by: Plane AI <noreply@plane.so> --------- Co-authored-by: Plane AI <noreply@plane.so>
This commit is contained in:
@@ -113,6 +113,14 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Ensure the intake belongs to this board before writing: a
|
||||
# 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)
|
||||
|
||||
@@ -142,7 +150,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Sanitize description_html before saving to prevent stored XSS (GHSA-hh2r-3hwp-mvq3)
|
||||
# Sanitize description_html before saving to prevent stored XSS
|
||||
raw_description_html = request.data.get("issue", {}).get("description_html", "<p></p>")
|
||||
_, _, sanitized_description_html = validate_html_content(raw_description_html)
|
||||
safe_description_html = sanitized_description_html if sanitized_description_html is not None else "<p></p>"
|
||||
|
||||
@@ -70,6 +70,33 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
# 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 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
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 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Bind the caller-supplied issue_id to this board.
|
||||
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(
|
||||
@@ -301,7 +333,15 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
{"error": "Comments are not enabled for this project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
comment = IssueComment.objects.get(pk=pk, actor=request.user)
|
||||
# Bind the comment to this board + issue, matching create()/get_queryset().
|
||||
comment = IssueComment.objects.get(
|
||||
pk=pk,
|
||||
issue_id=issue_id,
|
||||
project_id=project_deploy_board.project_id,
|
||||
workspace_id=project_deploy_board.workspace_id,
|
||||
access="EXTERNAL",
|
||||
actor=request.user,
|
||||
)
|
||||
serializer = IssueCommentSerializer(comment, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -325,7 +365,15 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
{"error": "Comments are not enabled for this project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
comment = IssueComment.objects.get(pk=pk, actor=request.user)
|
||||
# Bind the comment to this board + issue, matching create()/get_queryset().
|
||||
comment = IssueComment.objects.get(
|
||||
pk=pk,
|
||||
issue_id=issue_id,
|
||||
project_id=project_deploy_board.project_id,
|
||||
workspace_id=project_deploy_board.workspace_id,
|
||||
access="EXTERNAL",
|
||||
actor=request.user,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment.activity.deleted",
|
||||
requested_data=json.dumps({"comment_id": str(pk)}),
|
||||
@@ -372,6 +420,10 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Bind the caller-supplied issue_id to this board.
|
||||
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(
|
||||
@@ -408,8 +460,12 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
{"error": "Reactions are not enabled for this project board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Bind the reaction to this board's project, not just its workspace,
|
||||
# matching create() - otherwise a reaction on an issue in a different
|
||||
# project of the same workspace could be reached through this board.
|
||||
issue_reaction = IssueReaction.objects.get(
|
||||
workspace_id=project_deploy_board.workspace_id,
|
||||
project_id=project_deploy_board.project_id,
|
||||
issue_id=issue_id,
|
||||
reaction=reaction_code,
|
||||
actor=request.user,
|
||||
@@ -441,6 +497,10 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
.filter(workspace_id=project_deploy_board.workspace_id)
|
||||
.filter(project_id=project_deploy_board.project_id)
|
||||
.filter(comment_id=self.kwargs.get("comment_id"))
|
||||
# Only reactions on public (EXTERNAL) comments are visible
|
||||
# through the public board, matching create()'s
|
||||
# _comment_in_board_scope check.
|
||||
.filter(comment__access="EXTERNAL")
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
@@ -457,6 +517,10 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Bind the caller-supplied comment_id to this board.
|
||||
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(
|
||||
@@ -478,7 +542,13 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
# This route's URL only supplies `anchor` and `comment_id`,
|
||||
# never `project_id` - self.kwargs.get("project_id") was
|
||||
# always None here, silently corrupting the activity log for
|
||||
# every comment reaction created on a public board. Use the
|
||||
# project resolved from the deploy board, matching destroy()
|
||||
# below.
|
||||
project_id=str(project_deploy_board.project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
@@ -493,10 +563,13 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Only a reaction on a public (EXTERNAL) comment bound to this board
|
||||
# can be removed through it, matching create() and get_queryset().
|
||||
comment_reaction = CommentReaction.objects.get(
|
||||
project_id=project_deploy_board.project_id,
|
||||
workspace_id=project_deploy_board.workspace_id,
|
||||
comment_id=comment_id,
|
||||
comment__access="EXTERNAL",
|
||||
reaction=reaction_code,
|
||||
actor=request.user,
|
||||
)
|
||||
@@ -542,6 +615,17 @@ class IssueVotePublicViewSet(BaseViewSet):
|
||||
|
||||
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.
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
# 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 WEB-8283.
|
||||
|
||||
The public Spaces board write endpoints (``/api/public/anchor/<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 (
|
||||
CommentReaction,
|
||||
DeployBoard,
|
||||
Intake,
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueReaction,
|
||||
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/"
|
||||
|
||||
|
||||
def comment_detail_url(anchor, issue_id, pk):
|
||||
return f"/api/public/anchor/{anchor}/issues/{issue_id}/comments/{pk}/"
|
||||
|
||||
|
||||
def issue_reaction_detail_url(anchor, issue_id, reaction_code):
|
||||
return f"/api/public/anchor/{anchor}/issues/{issue_id}/reactions/{reaction_code}/"
|
||||
|
||||
|
||||
def comment_reaction_detail_url(anchor, comment_id, reaction_code):
|
||||
return f"/api/public/anchor/{anchor}/comments/{comment_id}/reactions/{reaction_code}/"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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="<p>board external comment</p>",
|
||||
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="<p>secret external comment</p>",
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def internal_comment(db, workspace, board, create_user):
|
||||
"""An INTERNAL (non-public) comment on the board's own issue, with its own
|
||||
reaction - never shown on the public board, so it must not be readable or
|
||||
mutable through the public reaction endpoints either."""
|
||||
comment = IssueComment.objects.create(
|
||||
issue=board["issue"],
|
||||
project=board["project"],
|
||||
workspace=workspace,
|
||||
comment_html="<p>internal only</p>",
|
||||
access="INTERNAL",
|
||||
created_by=create_user,
|
||||
actor=create_user,
|
||||
)
|
||||
reaction = CommentReaction.objects.create(
|
||||
project=board["project"],
|
||||
workspace=workspace,
|
||||
comment=comment,
|
||||
actor=create_user,
|
||||
reaction="fire",
|
||||
)
|
||||
return {"comment": comment, "reaction": reaction}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def board_issue_reaction(db, workspace, board, create_user):
|
||||
"""A reaction already on the board's own issue, for destroy() coverage."""
|
||||
return IssueReaction.objects.create(
|
||||
project=board["project"],
|
||||
workspace=workspace,
|
||||
issue=board["issue"],
|
||||
actor=create_user,
|
||||
reaction="smile",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_issue_reaction(db, victim, create_user):
|
||||
"""A reaction the same user owns, but on an issue in a DIFFERENT project
|
||||
(not published by ``board``) - exercises the project binding on destroy()."""
|
||||
return IssueReaction.objects.create(
|
||||
project=victim["project"],
|
||||
workspace=victim["project"].workspace,
|
||||
issue=victim["issue"],
|
||||
actor=create_user,
|
||||
reaction="smile",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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": "<p>injected</p>"},
|
||||
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="<p>injected</p>"
|
||||
).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": "<p>injected cross-ws</p>"},
|
||||
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="<p>injected cross-ws</p>"
|
||||
).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": "<p>legit</p>"},
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Read/update/delete scope gaps — the board-scope guards were only applied to
|
||||
# create(). A caller could still read reactions on an INTERNAL (non-public)
|
||||
# comment, or reach a comment/reaction they authored through a board it does
|
||||
# not belong to, via list/update/delete. These mirror the create() coverage
|
||||
# above for the list/partial_update/destroy paths.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.contract
|
||||
class TestSpacesBoardObjectScopeMutations:
|
||||
# ----------------------------------------------------------------------- #
|
||||
# CommentReactionPublicViewSet — INTERNAL comments must stay invisible
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_comment_reaction_list_does_not_leak_internal_comment(self, session_client, board, internal_comment):
|
||||
response = session_client.get(comment_reactions_url(board["anchor"], internal_comment["comment"].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(r["id"]) for r in results}
|
||||
assert str(internal_comment["reaction"].id) not in returned_ids, (
|
||||
"Reaction on an INTERNAL comment leaked through the public reaction list"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_delete_reaction_on_internal_comment(self, session_client, board, internal_comment):
|
||||
response = session_client.delete(
|
||||
comment_reaction_detail_url(board["anchor"], internal_comment["comment"].id, "fire")
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert CommentReaction.objects.filter(id=internal_comment["reaction"].id).exists(), (
|
||||
"Reaction on an INTERNAL comment was deleted through the public board"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_delete_own_reaction_on_external_comment(self, session_client, board, create_user):
|
||||
reaction = CommentReaction.objects.create(
|
||||
project=board["project"],
|
||||
workspace=board["project"].workspace,
|
||||
comment=board["comment"],
|
||||
actor=create_user,
|
||||
reaction="tada",
|
||||
)
|
||||
response = session_client.delete(comment_reaction_detail_url(board["anchor"], board["comment"].id, "tada"))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert not CommentReaction.objects.filter(id=reaction.id).exists()
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# IssueCommentPublicViewSet.partial_update / destroy — must bind pk to
|
||||
# this board's project + workspace + issue_id, not just the actor
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_update_comment_reached_through_wrong_board(self, session_client, board, victim):
|
||||
response = session_client.patch(
|
||||
comment_detail_url(board["anchor"], board["issue"].id, victim["comment"].id),
|
||||
{"comment_html": "<p>hijacked</p>"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
victim["comment"].refresh_from_db()
|
||||
assert victim["comment"].comment_html != "<p>hijacked</p>"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_delete_comment_reached_through_wrong_board(self, session_client, board, victim):
|
||||
response = session_client.delete(comment_detail_url(board["anchor"], board["issue"].id, victim["comment"].id))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert IssueComment.objects.filter(id=victim["comment"].id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_update_own_comment_in_board_project(self, session_client, board):
|
||||
response = session_client.patch(
|
||||
comment_detail_url(board["anchor"], board["issue"].id, board["comment"].id),
|
||||
{"comment_html": "<p>edited</p>"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_delete_own_comment_in_board_project(self, session_client, board):
|
||||
response = session_client.delete(comment_detail_url(board["anchor"], board["issue"].id, board["comment"].id))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert not IssueComment.objects.filter(id=board["comment"].id).exists()
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# IssueReactionPublicViewSet.destroy — must bind to project, not just
|
||||
# workspace
|
||||
# ----------------------------------------------------------------------- #
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_delete_reaction_on_issue_outside_board_project(
|
||||
self, session_client, board, victim, victim_issue_reaction
|
||||
):
|
||||
response = session_client.delete(issue_reaction_detail_url(board["anchor"], victim["issue"].id, "smile"))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert IssueReaction.objects.filter(id=victim_issue_reaction.id).exists(), (
|
||||
"Reaction on an issue outside the board's project was deleted through this board"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_delete_own_reaction_on_issue_in_board_project(self, session_client, board, board_issue_reaction):
|
||||
response = session_client.delete(issue_reaction_detail_url(board["anchor"], board["issue"].id, "smile"))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert not IssueReaction.objects.filter(id=board_issue_reaction.id).exists()
|
||||
Reference in New Issue
Block a user