jira/apps/api/plane/app/views/asset/base.py
Manish Gupta 4577dc3f7a
[WEB-7776] fix(security): scope FileAsset queries to prevent cross-project IDOR (Cluster F) (#9288)
* [WEB-7776] fix(security): scope FileAsset queries to prevent cross-project IDOR (Cluster F)

Multiple asset endpoints were missing project-level scoping on FileAsset
queryset filters, allowing authenticated users to access, mark-uploaded,
or restore assets belonging to other projects/workspaces.

- ProjectBulkAssetEndpoint.post: add project_id= scope to asset filter
- EntityAssetEndpoint.get/patch: add project_id=deploy_board.project_id
- AssetRestoreEndpoint.post: add project_id=deploy_board.project_id
- FileAssetEndpoint (V1): add workspace membership check on get/post/delete
- FileAssetViewSet.restore (V1): add workspace membership check
- WorkspaceFileAssetEndpoint.post: gate WORKSPACE_LOGO on ADMIN role
- DuplicateAssetEndpoint.post: restrict source asset to same workspace

Fixes GHSA-r2hw, GHSA-jh4v, GHSA-8688, GHSA-3hrj and related advisories.

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

* refactor(security): replace inline membership checks with WorkspaceMemberPermission class

Add WorkspaceMemberPermission to workspace.py — resolves workspace by
'workspace_id' UUID or 'slug' kwarg, covering the mixed URL patterns on
FileAssetEndpoint. Apply to FileAssetEndpoint and FileAssetViewSet so
membership enforcement lives in the permission layer, not inside each
method handler.

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

* refactor: remove dead 404 guard in FileAssetEndpoint.post()

WorkspaceMemberPermission denies requests for non-existent slugs before
the view method runs, making the filter().first() + if not workspace
branch unreachable. Switch to .get() so any TOCTOU race still surfaces
as a 404 via ObjectDoesNotExist.

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

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-06-30 18:26:59 +05:30

92 lines
3.8 KiB
Python

# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from ..base import BaseAPIView, BaseViewSet
from plane.app.permissions import WorkspaceMemberPermission
from plane.db.models import FileAsset, Workspace
from plane.app.serializers import FileAssetSerializer
class FileAssetEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser, JSONParser)
permission_classes = [IsAuthenticated, WorkspaceMemberPermission]
"""
A viewset for viewing and editing task instances.
"""
def get(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
files = FileAsset.objects.filter(asset=asset_key)
if files.exists():
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
else:
return Response(
{"error": "Asset key does not exist", "status": False},
status=status.HTTP_200_OK,
)
def post(self, request, slug):
# WorkspaceMemberPermission already rejects unknown slugs before this runs.
# Use .get() so any TOCTOU race still surfaces as a 404 via ObjectDoesNotExist.
workspace = Workspace.objects.get(slug=slug)
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = True
file_asset.save(update_fields=["is_deleted"])
return Response(status=status.HTTP_204_NO_CONTENT)
class FileAssetViewSet(BaseViewSet):
permission_classes = [IsAuthenticated, WorkspaceMemberPermission]
def restore(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = False
file_asset.save(update_fields=["is_deleted"])
return Response(status=status.HTTP_204_NO_CONTENT)
class UserAssetsEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
def get(self, request, asset_key):
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
if files.exists():
serializer = FileAssetSerializer(files, context={"request": request})
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
else:
return Response(
{"error": "Asset key does not exist", "status": False},
status=status.HTTP_200_OK,
)
def post(self, request):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, asset_key):
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
file_asset.is_deleted = True
file_asset.save(update_fields=["is_deleted"])
return Response(status=status.HTTP_204_NO_CONTENT)