Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
9 changes: 8 additions & 1 deletion apps/api/plane/app/views/issue/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ def get_queryset(self):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id, issue_id):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(pk=issue_id)
# SECURITY: bind the issue to the URL workspace + project. `allow_permission` only
# checks that the caller is a member of `project_id`, never that `issue_id` lives in
# it, so a bare pk lookup let any project member comment on an issue in another
# project/workspace — and read that issue's full `issue_detail` back out of the 201
# response (GHSA-hvx3-58mp-5fpx).
issue = Issue.objects.filter(pk=issue_id, workspace__slug=slug, project_id=project_id).first()
if issue is None:
return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
Expand Down
37 changes: 37 additions & 0 deletions apps/api/plane/app/views/issue/relation.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,30 @@
from plane.utils.host import base_host


def issue_in_project(issue_id, slug, project_id):
"""Whether ``issue_id`` belongs to the URL workspace + project.

``ProjectEntityPermission`` only checks that the caller is a member of the URL's
``project_id`` — it never binds the sibling ``issue_id`` path parameter to that
project. Every handler taking both must therefore check this itself, or it is
reachable cross-project/cross-tenant (GHSA-hvx3-58mp-5fpx).
"""
return Issue.issue_objects.filter(pk=issue_id, workspace__slug=slug, project_id=project_id).exists()


class IssueRelationViewSet(BaseViewSet):
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [ProjectEntityPermission]

def list(self, request, slug, project_id, issue_id):
# SECURITY: the read path needs the same binding as the writes below. Without
# it a member of project A could list the relations of an issue in project B of
# the same workspace and receive that issue's name, priority, assignees and
# labels in the response.
if not issue_in_project(issue_id, slug, project_id):
return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND)

issue_relations = (
IssueRelation.objects.filter(Q(issue_id=issue_id) | Q(related_issue=issue_id))
.filter(workspace__slug=self.kwargs.get("slug"))
Expand Down Expand Up @@ -217,6 +235,13 @@ def create(self, request, slug, project_id, issue_id):
issues = request.data.get("issues", [])
project = Project.objects.get(pk=project_id)

# SECURITY: bind the URL issue to the workspace + project before using it as one
# side of the relation. The body `issues` are scoped below, but `issue_id` came
# straight from the URL — so the earlier scoping fix covered one side of the
# relationship and missed the other.
if not issue_in_project(issue_id, slug, project_id):
return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND)
Comment thread
mguptahub marked this conversation as resolved.

# Scope to workspace to prevent cross-tenant IDOR
# Relations can cross projects so only workspace scope is enforced
issues = list(
Expand Down Expand Up @@ -271,12 +296,24 @@ def create(self, request, slug, project_id, issue_id):
def remove_relation(self, request, slug, project_id, issue_id):
related_issue = request.data.get("related_issue", None)

Comment thread
mguptahub marked this conversation as resolved.
# SECURITY: same binding as create() — the URL issue must belong to the URL
# project before it can be used to select a relation for deletion. Otherwise a
# member of one project could delete relations between issues of a sibling
# project in the same workspace. The IssueRelation row itself stays
# workspace-scoped only: relations legitimately span projects, and either
# participant's project may remove them.
if not issue_in_project(issue_id, slug, project_id):
return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND)

issue_relations = IssueRelation.objects.filter(
workspace__slug=slug,
).filter(
Q(issue_id=related_issue, related_issue_id=issue_id) | Q(issue_id=issue_id, related_issue_id=related_issue)
)
issue_relations = issue_relations.first()
if issue_relations is None:
# Previously fell through to `None.delete()` -> AttributeError -> 500.
return Response({"error": "Issue relation not found"}, status=status.HTTP_404_NOT_FOUND)
current_instance = json.dumps(IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder)
issue_relations.delete()
issue_activity.delay(
Expand Down
Loading
Loading