diff --git a/apps/api/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py index 5b55a76a611..fd1b50872e8 100644 --- a/apps/api/plane/app/views/asset/base.py +++ b/apps/api/plane/app/views/asset/base.py @@ -9,7 +9,7 @@ # Module imports from ..base import BaseAPIView, BaseViewSet -from plane.db.models import FileAsset, Workspace +from plane.db.models import FileAsset, Workspace, WorkspaceMember from plane.app.serializers import FileAssetSerializer @@ -21,6 +21,12 @@ class FileAssetEndpoint(BaseAPIView): """ def get(self, request, workspace_id, asset_key): + # Verify the requesting user is a member of this workspace + if not WorkspaceMember.objects.filter(workspace_id=workspace_id, member=request.user, is_active=True).exists(): + return Response( + {"error": "Requested resource could not be found.", "status": False}, + status=status.HTTP_404_NOT_FOUND, + ) asset_key = str(workspace_id) + "/" + asset_key files = FileAsset.objects.filter(asset=asset_key) if files.exists(): @@ -33,15 +39,25 @@ def get(self, request, workspace_id, asset_key): ) def post(self, request, slug): + # Verify the requesting user is a member of this workspace + workspace = Workspace.objects.filter(slug=slug).first() + if not workspace: + return Response({"error": "Workspace not found.", "status": False}, status=status.HTTP_404_NOT_FOUND) + if not WorkspaceMember.objects.filter(workspace=workspace, member=request.user, is_active=True).exists(): + return Response( + {"error": "Requested resource could not be found.", "status": False}, + status=status.HTTP_404_NOT_FOUND, + ) serializer = FileAssetSerializer(data=request.data) if serializer.is_valid(): - # Get the workspace - workspace = Workspace.objects.get(slug=slug) 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): + # Verify the requesting user is a member of this workspace + if not WorkspaceMember.objects.filter(workspace_id=workspace_id, member=request.user, is_active=True).exists(): + return Response({"error": "Requested resource could not be found."}, status=status.HTTP_404_NOT_FOUND) asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) file_asset.is_deleted = True @@ -51,6 +67,9 @@ def delete(self, request, workspace_id, asset_key): class FileAssetViewSet(BaseViewSet): def restore(self, request, workspace_id, asset_key): + # Verify the requesting user is a member of this workspace + if not WorkspaceMember.objects.filter(workspace_id=workspace_id, member=request.user, is_active=True).exists(): + return Response({"error": "Requested resource could not be found."}, status=status.HTTP_404_NOT_FOUND) asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) file_asset.is_deleted = False diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index b21f70d61fc..8441364f58d 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -327,6 +327,17 @@ def post(self, request, slug): status=status.HTTP_400_BAD_REQUEST, ) + # WORKSPACE_LOGO may only be uploaded by workspace admins + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + workspace_member = WorkspaceMember.objects.filter( + workspace__slug=slug, member=request.user, is_active=True + ).first() + if not workspace_member or workspace_member.role != ROLE.ADMIN.value: + return Response( + {"error": "Only workspace admins can upload a workspace logo."}, + status=status.HTTP_403_FORBIDDEN, + ) + # Check if the file type is allowed allowed_types = [ "image/jpeg", @@ -646,8 +657,8 @@ def post(self, request, slug, project_id, entity_id): if not asset_ids: return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST) - # get the asset id - assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug) + # get the asset id — scope to the project to prevent cross-project IDOR + assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug, project_id=project_id) # Get the first asset asset = assets.first() @@ -757,15 +768,11 @@ def post(self, request, slug, asset_id): return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND) storage = S3Storage(request=request) - # Scope the source asset lookup to workspaces the caller is a member of - user_workspace_ids = WorkspaceMember.objects.filter( - member=request.user, - is_active=True, - ).values_list("workspace_id", flat=True) + # Restrict the source asset to the same destination workspace to prevent cross-workspace asset copying original_asset = FileAsset.objects.filter( id=asset_id, is_uploaded=True, - workspace_id__in=user_workspace_ids, + workspace=workspace, ).first() if not original_asset: diff --git a/apps/api/plane/space/views/asset.py b/apps/api/plane/space/views/asset.py index bc20724ca80..5220202fefd 100644 --- a/apps/api/plane/space/views/asset.py +++ b/apps/api/plane/space/views/asset.py @@ -42,9 +42,10 @@ def get(self, request, anchor, pk): status=status.HTTP_404_NOT_FOUND, ) - # get the asset id + # get the asset id — scope to project to prevent cross-project IDOR asset = FileAsset.objects.get( workspace_id=deploy_board.workspace_id, + project_id=deploy_board.project_id, pk=pk, entity_type__in=[ FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, @@ -140,8 +141,8 @@ def patch(self, request, anchor, pk): if not deploy_board: return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) - # get the asset id - asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace) + # get the asset id — scope to project to prevent cross-project IDOR + asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id) # get the storage metadata asset.is_uploaded = True # get the storage metadata @@ -180,8 +181,8 @@ def post(self, request, anchor, pk): if not deploy_board: return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) - # Get the asset - asset = FileAsset.all_objects.get(id=pk, workspace=deploy_board.workspace) + # Get the asset — scope to project to prevent cross-project IDOR + asset = FileAsset.all_objects.get(id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id) asset.is_deleted = False asset.deleted_at = None asset.save(update_fields=["is_deleted", "deleted_at"])