From e063c2cf9b0026c072cc6840a400325d0c90a9d6 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Mon, 22 Jun 2026 16:06:25 +0530 Subject: [PATCH 1/3] [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 --- apps/api/plane/app/views/asset/base.py | 25 ++++++++++++++++++++++--- apps/api/plane/app/views/asset/v2.py | 23 +++++++++++++++-------- apps/api/plane/space/views/asset.py | 11 ++++++----- 3 files changed, 43 insertions(+), 16 deletions(-) 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"]) From ea9ecdfef9bdc52d53c7a588c5876ce7f7e42b40 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Wed, 24 Jun 2026 12:33:51 +0530 Subject: [PATCH 2/3] refactor(security): replace inline membership checks with WorkspaceMemberPermission class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/plane/app/permissions/__init__.py | 1 + apps/api/plane/app/permissions/workspace.py | 27 +++++++++++++++++++++ apps/api/plane/app/views/asset/base.py | 25 +++++-------------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/apps/api/plane/app/permissions/__init__.py b/apps/api/plane/app/permissions/__init__.py index 22d27694e9f..457a13cb7bf 100644 --- a/apps/api/plane/app/permissions/__init__.py +++ b/apps/api/plane/app/permissions/__init__.py @@ -9,6 +9,7 @@ WorkspaceEntityPermission, WorkspaceViewerPermission, WorkspaceUserPermission, + WorkspaceMemberPermission, ) from .project import ( ProjectBasePermission, diff --git a/apps/api/plane/app/permissions/workspace.py b/apps/api/plane/app/permissions/workspace.py index ada16ec3b5a..75e9fd61db2 100644 --- a/apps/api/plane/app/permissions/workspace.py +++ b/apps/api/plane/app/permissions/workspace.py @@ -108,3 +108,30 @@ def has_permission(self, request, view): return WorkspaceMember.objects.filter( member=request.user, workspace__slug=view.workspace_slug, is_active=True ).exists() + + +class WorkspaceMemberPermission(BasePermission): + """Allows access only to active workspace members. + + Resolves the workspace via 'slug' or 'workspace_id' in URL kwargs so this + class can be used on endpoints that identify the workspace by either + identifier (e.g. FileAssetEndpoint which mixes both URL patterns). + """ + + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + workspace_id = view.kwargs.get("workspace_id") + if workspace_id: + return WorkspaceMember.objects.filter( + workspace_id=workspace_id, member=request.user, is_active=True + ).exists() + + slug = view.kwargs.get("slug") + if slug: + return WorkspaceMember.objects.filter( + workspace__slug=slug, member=request.user, is_active=True + ).exists() + + return False diff --git a/apps/api/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py index fd1b50872e8..7a3a6b3b160 100644 --- a/apps/api/plane/app/views/asset/base.py +++ b/apps/api/plane/app/views/asset/base.py @@ -4,29 +4,26 @@ # 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.db.models import FileAsset, Workspace, WorkspaceMember +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): - # 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(): @@ -39,15 +36,9 @@ 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(): serializer.save(workspace_id=workspace.id) @@ -55,9 +46,6 @@ def post(self, request, slug): 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 @@ -66,10 +54,9 @@ def delete(self, request, workspace_id, asset_key): class FileAssetViewSet(BaseViewSet): + permission_classes = [IsAuthenticated, WorkspaceMemberPermission] + 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 From 70143f80d49406bd77d50715e7551fa7224f7816 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Wed, 24 Jun 2026 12:50:40 +0530 Subject: [PATCH 3/3] 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 --- apps/api/plane/app/views/asset/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py index 7a3a6b3b160..af7a547e06f 100644 --- a/apps/api/plane/app/views/asset/base.py +++ b/apps/api/plane/app/views/asset/base.py @@ -36,9 +36,9 @@ def get(self, request, workspace_id, asset_key): ) def post(self, request, slug): - workspace = Workspace.objects.filter(slug=slug).first() - if not workspace: - return Response({"error": "Workspace not found.", "status": False}, status=status.HTTP_404_NOT_FOUND) + # 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)