* [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>
92 lines
3.8 KiB
Python
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)
|