From 49c4da6d4b1f32e07131d6ed49958f9fce2ff4fa Mon Sep 17 00:00:00 2001 From: Karthikeyan Ganesh Date: Tue, 28 Jul 2026 17:52:46 +0530 Subject: [PATCH 1/2] fix: strip control characters from sanitized filenames (#9151) Prevent tab, newline, and other ASCII control characters from appearing in S3 object keys generated from user-provided upload filenames. Fixes #9127 Co-authored-by: Cursor --- apps/api/plane/utils/path_validator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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("\\", "/") From 15e835710c7f938e0fae9c0ee77bb8162ff436a0 Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:16:05 +0530 Subject: [PATCH 2/2] [SECUR-242] fix(api): scope bulk-asset associate by uploader, not project_id (regression from #9288) (#9495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [SECUR-242] fix(api): scope bulk-asset associate by uploader, not project_id Regression from #9288 (WEB-7776, cross-project IDOR scoping): adding project_id=project_id to ProjectBulkAssetEndpoint.post broke project creation — the "enable features" step 404s because the freshly-uploaded cover/feature asset still has project_id=NULL (this endpoint is what sets it). master had no such filter. Scope the lookup by created_by=request.user instead. This still closes the IDOR (#9288) — a caller can only touch assets they uploaded, and @allow_permission already scopes them to the project — and is stricter than the original master code (which had no ownership check), while allowing not-yet-associated assets to be linked. Co-Authored-By: Claude Opus 4.8 (1M context) * [SECUR-242] fix: bound bulk-asset associate to unassociated-or-same-project (CodeRabbit) Address CodeRabbit: created_by alone let a user move their own asset from another project into this one via the PROJECT_COVER/ISSUE_DESCRIPTION update branches. Add an unassociated-or-same-project bound (project_id=project_id OR project_id IS NULL) alongside created_by, so freshly-uploaded (NULL) assets still link but cross-project moves are rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- apps/api/plane/app/views/asset/v2.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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()