diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index 3b6d4dce8bc..6d835f6a225 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -10,6 +10,7 @@ from django.http import HttpResponseRedirect from django.utils import timezone from django.db import IntegrityError +from django.db.models import Q # Third party imports from rest_framework import status @@ -708,8 +709,19 @@ 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 — scope to the project to prevent cross-project IDOR - assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug, project_id=project_id) + # Scope to the requester's own uploads in this workspace, limited to assets that are + # either unassociated or already in this project. This endpoint *associates* + # freshly-uploaded assets, which are not yet project-scoped (e.g. a cover uploaded + # during project creation has project_id=NULL until this call sets it) — so the + # earlier project_id=project_id filter 404'd that flow. created_by + the + # unassociated-or-same-project bound prevent cross-project/user IDOR (a caller can + # only touch their own uploads, cannot move an asset in from another project, and + # @allow_permission already scopes them to this project). + assets = FileAsset.objects.filter( + id__in=asset_ids, + workspace__slug=slug, + created_by=request.user, + ).filter(Q(project_id=project_id) | Q(project_id__isnull=True)) # Get the first asset asset = assets.first() diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index ab2e2bae537..2ea71c18f23 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -15,8 +15,8 @@ def sanitize_filename(filename): """ Sanitize a filename to prevent path traversal attacks. - Strips directory components, path traversal sequences, and null bytes - from user-supplied filenames used in upload paths and S3 object keys. + Strips directory components, path traversal sequences, and control + characters from user-supplied filenames used in upload paths and S3 object keys. Returns None for empty/missing input so callers can still validate that a filename was provided. @@ -24,8 +24,8 @@ def sanitize_filename(filename): if not filename or not isinstance(filename, str): return None - # Strip null bytes - filename = filename.replace("\x00", "") + # Strip ASCII control characters (0-31 and 127), including null bytes + filename = "".join(char for char in filename if not (ord(char) < 32 or ord(char) == 127)) # Normalize backslashes so os.path.basename handles Windows-style paths on POSIX filename = filename.replace("\\", "/")