Skip to content

Commit aea66f5

Browse files
fix: sanitize filenames in upload paths to prevent path traversal (#8879)
* fix: sanitize filenames in upload paths to prevent path traversal (GHSA-v57h-5999-w7xp) Add server-side filename sanitization across all file upload endpoints to prevent path traversal sequences (../) in user-supplied filenames from being incorporated into S3 object keys. While S3 keys are flat strings and not vulnerable to filesystem traversal, this adds defense-in-depth and prevents S3 key pollution. Changes: - Add sanitize_filename() utility in path_validator.py - Sanitize filenames in get_upload_path() for FileAsset and IssueAttachment models - Sanitize name parameter in all upload view endpoints * fix: address PR review feedback on filename sanitization - Remove unused `import re` - Normalize backslashes to forward slashes before os.path.basename() so Windows-style paths (e.g. ..\..\..\evil.txt) are handled on POSIX - Strip whitespace before removing leading dots so " .env" is caught - Return None instead of "unnamed" for empty input so existing `if not name` validation guards remain effective - Add `or "unnamed"` fallback at call sites that lack a name guard * fix: use random hex name as fallback in get_upload_path instead of "unnamed" * fix: resolve ruff E501 line too long in DuplicateAssetEndpoint
1 parent 45b4fc8 commit aea66f5

8 files changed

Lines changed: 62 additions & 10 deletions

File tree

apps/api/plane/api/views/asset.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# Module Imports
1818
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
1919
from plane.settings.storage import S3Storage
20+
from plane.utils.path_validator import sanitize_filename
2021
from plane.db.models import FileAsset, User, Workspace
2122
from plane.api.views.base import BaseAPIView
2223
from plane.api.serializers import (
@@ -114,7 +115,7 @@ def post(self, request):
114115
This endpoint generates the necessary credentials for direct S3 upload.
115116
"""
116117
# get the asset key
117-
name = request.data.get("name")
118+
name = sanitize_filename(request.data.get("name")) or "unnamed"
118119
type = request.data.get("type", "image/jpeg")
119120
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
120121
entity_type = request.data.get("entity_type", False)
@@ -287,7 +288,7 @@ def post(self, request):
287288
necessary credentials for direct S3 upload with server-side authentication.
288289
"""
289290
# get the asset key
290-
name = request.data.get("name")
291+
name = sanitize_filename(request.data.get("name")) or "unnamed"
291292
type = request.data.get("type", "image/jpeg")
292293
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
293294
entity_type = request.data.get("entity_type", False)
@@ -498,7 +499,7 @@ def post(self, request, slug):
498499
Create a presigned URL for uploading generic assets that can be bound to entities like work items.
499500
Supports various file types and includes external source tracking for integrations.
500501
"""
501-
name = request.data.get("name")
502+
name = sanitize_filename(request.data.get("name"))
502503
type = request.data.get("type")
503504
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
504505
project_id = request.data.get("project_id")

apps/api/plane/api/views/issue.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
Workspace,
8080
)
8181
from plane.settings.storage import S3Storage
82+
from plane.utils.path_validator import sanitize_filename
8283
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
8384
from .base import BaseAPIView
8485
from plane.utils.host import base_host
@@ -1858,7 +1859,7 @@ def post(self, request, slug, project_id, issue_id):
18581859
status=status.HTTP_403_FORBIDDEN,
18591860
)
18601861

1861-
name = request.data.get("name")
1862+
name = sanitize_filename(request.data.get("name"))
18621863
type = request.data.get("type", False)
18631864
size = request.data.get("size")
18641865
external_id = request.data.get("external_id")

apps/api/plane/app/views/asset/v2.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from plane.settings.storage import S3Storage
2323
from plane.app.permissions import allow_permission, ROLE
2424
from plane.utils.cache import invalidate_cache_directly
25+
from plane.utils.path_validator import sanitize_filename
2526
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
2627
from plane.throttles.asset import AssetRateThrottle
2728

@@ -108,7 +109,7 @@ def entity_asset_delete(self, entity_type, asset, request):
108109

109110
def post(self, request):
110111
# get the asset key
111-
name = request.data.get("name")
112+
name = sanitize_filename(request.data.get("name")) or "unnamed"
112113
type = request.data.get("type", "image/jpeg")
113114
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
114115
entity_type = request.data.get("entity_type", False)
@@ -313,7 +314,7 @@ def entity_asset_delete(self, entity_type, asset, request):
313314

314315
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
315316
def post(self, request, slug):
316-
name = request.data.get("name")
317+
name = sanitize_filename(request.data.get("name")) or "unnamed"
317318
type = request.data.get("type", "image/jpeg")
318319
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
319320
entity_type = request.data.get("entity_type")
@@ -515,7 +516,7 @@ def get_entity_id_field(self, entity_type, entity_id):
515516

516517
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
517518
def post(self, request, slug, project_id):
518-
name = request.data.get("name")
519+
name = sanitize_filename(request.data.get("name")) or "unnamed"
519520
type = request.data.get("type", "image/jpeg")
520521
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
521522
entity_type = request.data.get("entity_type", "")
@@ -770,7 +771,8 @@ def post(self, request, slug, asset_id):
770771
if not original_asset:
771772
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
772773

773-
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
774+
sanitized_name = sanitize_filename(original_asset.attributes.get("name")) or "unnamed"
775+
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{sanitized_name}"
774776
duplicated_asset = FileAsset.objects.create(
775777
attributes={
776778
"name": original_asset.attributes.get("name"),

apps/api/plane/app/views/issue/attachment.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from plane.bgtasks.issue_activities_task import issue_activity
2525
from plane.app.permissions import allow_permission, ROLE
2626
from plane.settings.storage import S3Storage
27+
from plane.utils.path_validator import sanitize_filename
2728
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
2829
from plane.utils.host import base_host
2930

@@ -97,7 +98,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
9798

9899
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
99100
def post(self, request, slug, project_id, issue_id):
100-
name = request.data.get("name")
101+
name = sanitize_filename(request.data.get("name")) or "unnamed"
101102
type = request.data.get("type", False)
102103
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
103104

apps/api/plane/db/models/asset.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111
from django.db import models
1212

1313
# Module import
14+
from plane.utils.path_validator import sanitize_filename
15+
1416
from .base import BaseModel
1517

1618

1719
def get_upload_path(instance, filename):
20+
filename = sanitize_filename(filename) or uuid4().hex
1821
if instance.workspace_id is not None:
1922
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
2023
return f"user-{uuid4().hex}-{filename}"

apps/api/plane/db/models/issue.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
# Module imports
1919
from plane.utils.html_processor import strip_tags
20+
from plane.utils.path_validator import sanitize_filename
2021
from plane.db.mixins import SoftDeletionManager
2122
from plane.utils.exception_logger import log_exception
2223
from .project import ProjectBaseModel
@@ -376,6 +377,7 @@ def __str__(self):
376377

377378

378379
def get_upload_path(instance, filename):
380+
filename = sanitize_filename(filename) or uuid4().hex
379381
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
380382

381383

apps/api/plane/space/views/asset.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
1919
from plane.db.models import DeployBoard, FileAsset
2020
from plane.settings.storage import S3Storage
21+
from plane.utils.path_validator import sanitize_filename
2122

2223
# Module imports
2324
from .base import BaseAPIView
@@ -73,7 +74,7 @@ def post(self, request, anchor):
7374
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
7475

7576
# Get the asset
76-
name = request.data.get("name")
77+
name = sanitize_filename(request.data.get("name")) or "unnamed"
7778
type = request.data.get("type", "image/jpeg")
7879
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
7980
entity_type = request.data.get("entity_type", "")

apps/api/plane/utils/path_validator.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,50 @@
77
from django.conf import settings
88

99
# Python imports
10+
import os
1011
from urllib.parse import urlparse
1112

1213

14+
def sanitize_filename(filename):
15+
"""
16+
Sanitize a filename to prevent path traversal attacks.
17+
18+
Strips directory components, path traversal sequences, and null bytes
19+
from user-supplied filenames used in upload paths and S3 object keys.
20+
21+
Returns None for empty/missing input so callers can still validate
22+
that a filename was provided.
23+
"""
24+
if not filename or not isinstance(filename, str):
25+
return None
26+
27+
# Strip null bytes
28+
filename = filename.replace("\x00", "")
29+
30+
# Normalize backslashes so os.path.basename handles Windows-style paths on POSIX
31+
filename = filename.replace("\\", "/")
32+
33+
# Take only the basename to remove any directory components
34+
filename = os.path.basename(filename)
35+
36+
# Remove any remaining path traversal sequences
37+
filename = filename.replace("..", "")
38+
39+
# Strip whitespace before removing leading dots so " .env" is caught
40+
filename = filename.strip()
41+
42+
# Remove leading dots (hidden files)
43+
filename = filename.lstrip(".")
44+
45+
# Strip any remaining whitespace
46+
filename = filename.strip()
47+
48+
if not filename:
49+
return None
50+
51+
return filename
52+
53+
1354
def _contains_suspicious_patterns(path: str) -> bool:
1455
"""
1556
Check for suspicious patterns that might indicate malicious intent.

0 commit comments

Comments
 (0)