Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/app/permissions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
WorkspaceEntityPermission,
WorkspaceViewerPermission,
WorkspaceUserPermission,
WorkspaceMemberPermission,
)
from .project import (
ProjectBasePermission,
Expand Down
27 changes: 27 additions & 0 deletions apps/api/plane/app/permissions/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 8 additions & 2 deletions apps/api/plane/app/views/asset/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@

# 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]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

"""
A viewset for viewing and editing task instances.
Expand All @@ -33,10 +36,11 @@ def get(self, request, workspace_id, asset_key):
)

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():
# 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)
Expand All @@ -50,6 +54,8 @@ def delete(self, request, workspace_id, asset_key):


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)
Expand Down
23 changes: 15 additions & 8 deletions apps/api/plane/app/views/asset/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions apps/api/plane/space/views/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down
Loading