jira/apps/api/plane/app/views/issue/relation.py
Manish Gupta 81d9873d7f
[WEB-7727] fix(security): scope issue ID validation to workspace/project in bulk endpoints (#9269)
* fix(security): scope issue ID validation to workspace/project in bulk endpoints

Prevents cross-tenant IDOR by filtering incoming issue IDs through
workspace+project scope before bulk_create/bulk_update in:
- CycleIssueListCreateAPIEndpoint: validate new_issues against workspace+project (GHSA-22g9-9xfv-q3fr)
- SubIssuesEndpoint: validate sub_issue_ids against workspace (GHSA-38vj-gf85-7q5x)
- IssueRelationListCreateAPIEndpoint: validate issues against workspace (GHSA-8cvv-8jh5-g6mj)
- ModuleIssueListCreateAPIEndpoint: already scoped at line 673, no change needed (GHSA-x5c5-hmvm-94v9)

Co-authored-by: Plane AI <noreply@plane.so>

* fix(security): extend IDOR scope validation to app-layer endpoints

Same cross-tenant IDOR fix applied to the app/views/ counterparts
which are used by the web frontend (api/views/ covered in previous commit):
- app/views/cycle/issue.py: filter new_issues to workspace+project (GHSA-22g9-9xfv-q3fr)
- app/views/module/issue.py: filter issues to workspace+project before bulk_create (GHSA-x5c5-hmvm-94v9)
- app/views/issue/relation.py: filter issues to workspace before bulk_create (GHSA-8cvv-8jh5-g6mj)

Co-authored-by: Plane AI <noreply@plane.so>

* chore: remove advisory ID references from code comments

---------

Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2026-06-20 17:07:42 +05:30

293 lines
12 KiB
Python

# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import json
# Django imports
from django.utils import timezone
from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField, Subquery
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from plane.app.serializers import IssueRelationSerializer, RelatedIssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Project,
IssueRelation,
Issue,
FileAsset,
IssueLink,
CycleIssue,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_relation_mapper import get_actual_relation
from plane.utils.host import base_host
class IssueRelationViewSet(BaseViewSet):
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [ProjectEntityPermission]
def list(self, request, slug, project_id, issue_id):
issue_relations = (
IssueRelation.objects.filter(Q(issue_id=issue_id) | Q(related_issue=issue_id))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("issue")
.order_by("-created_at")
.distinct()
)
# get all blocking issues
blocking_issues = issue_relations.filter(relation_type="blocked_by", related_issue_id=issue_id).values_list(
"issue_id", flat=True
)
# get all blocked by issues
blocked_by_issues = issue_relations.filter(relation_type="blocked_by", issue_id=issue_id).values_list(
"related_issue_id", flat=True
)
# get all duplicate issues
duplicate_issues = issue_relations.filter(issue_id=issue_id, relation_type="duplicate").values_list(
"related_issue_id", flat=True
)
# get all relates to issues
duplicate_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="duplicate"
).values_list("issue_id", flat=True)
# get all relates to issues
relates_to_issues = issue_relations.filter(issue_id=issue_id, relation_type="relates_to").values_list(
"related_issue_id", flat=True
)
# get all relates to issues
relates_to_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="relates_to"
).values_list("issue_id", flat=True)
# get all start after issues
start_after_issues = issue_relations.filter(
relation_type="start_before", related_issue_id=issue_id
).values_list("issue_id", flat=True)
# get all start_before issues
start_before_issues = issue_relations.filter(relation_type="start_before", issue_id=issue_id).values_list(
"related_issue_id", flat=True
)
# get all finish after issues
finish_after_issues = issue_relations.filter(
relation_type="finish_before", related_issue_id=issue_id
).values_list("issue_id", flat=True)
# get all finish before issues
finish_before_issues = issue_relations.filter(relation_type="finish_before", issue_id=issue_id).values_list(
"related_issue_id", flat=True
)
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(~Q(labels__id__isnull=True) & (Q(label_issue__deleted_at__isnull=True))),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
# Fields
fields = [
"id",
"name",
"state_id",
"sort_order",
"priority",
"sequence_id",
"project_id",
"label_ids",
"assignee_ids",
"created_at",
"updated_at",
"created_by",
"updated_by",
"relation_type",
]
response_data = {
"blocking": queryset.filter(pk__in=blocking_issues)
.annotate(relation_type=Value("blocking", output_field=CharField()))
.values(*fields),
"blocked_by": queryset.filter(pk__in=blocked_by_issues)
.annotate(relation_type=Value("blocked_by", output_field=CharField()))
.values(*fields),
"duplicate": queryset.filter(pk__in=duplicate_issues)
.annotate(relation_type=Value("duplicate", output_field=CharField()))
.values(*fields)
| queryset.filter(pk__in=duplicate_issues_related)
.annotate(relation_type=Value("duplicate", output_field=CharField()))
.values(*fields),
"relates_to": queryset.filter(pk__in=relates_to_issues)
.annotate(relation_type=Value("relates_to", output_field=CharField()))
.values(*fields)
| queryset.filter(pk__in=relates_to_issues_related)
.annotate(relation_type=Value("relates_to", output_field=CharField()))
.values(*fields),
"start_after": queryset.filter(pk__in=start_after_issues)
.annotate(relation_type=Value("start_after", output_field=CharField()))
.values(*fields),
"start_before": queryset.filter(pk__in=start_before_issues)
.annotate(relation_type=Value("start_before", output_field=CharField()))
.values(*fields),
"finish_after": queryset.filter(pk__in=finish_after_issues)
.annotate(relation_type=Value("finish_after", output_field=CharField()))
.values(*fields),
"finish_before": queryset.filter(pk__in=finish_before_issues)
.annotate(relation_type=Value("finish_before", output_field=CharField()))
.values(*fields),
}
return Response(response_data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, issue_id):
relation_type = request.data.get("relation_type", None)
if relation_type is None:
return Response(
{"message": "Issue relation type is required"},
status=status.HTTP_400_BAD_REQUEST,
)
issues = request.data.get("issues", [])
project = Project.objects.get(pk=project_id)
# Scope to workspace to prevent cross-tenant IDOR
# Relations can cross projects so only workspace scope is enforced
issues = list(
Issue.issue_objects.filter(
workspace__slug=slug,
pk__in=issues,
).values_list("id", flat=True)
)
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=(issue if relation_type in ["blocking", "start_after", "finish_after"] else issue_id),
related_issue_id=(
issue_id if relation_type in ["blocking", "start_after", "finish_after"] else issue
),
relation_type=(get_actual_relation(relation_type)),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
issue_activity.delay(
type="issue_relation.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
if relation_type in ["blocking", "start_after", "finish_after"]:
return Response(
RelatedIssueSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
else:
return Response(
IssueRelationSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
def remove_relation(self, request, slug, project_id, issue_id):
related_issue = request.data.get("related_issue", None)
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()
current_instance = json.dumps(IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder)
issue_relations.delete()
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response(status=status.HTTP_204_NO_CONTENT)