From 5a4f3bbf95297146f23809052d2056105173ff27 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Thu, 25 Jun 2026 11:43:34 +0530 Subject: [PATCH 1/2] [WEB-7887] fix(security): prevent stored XSS via SVG attachment served inline (GHSA-ch8j-vr4r-qf6h) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SCRIPT_CAPABLE_MIME_TYPES frozenset (image/svg+xml, text/javascript, application/javascript, text/html, application/xhtml+xml, text/xml, application/xml) and enforce Content-Disposition: attachment on three download endpoints that previously defaulted to inline serving: - GenericAssetEndpoint.get (api/views/asset.py) - StaticFileAssetEndpoint.get (app/views/asset/v2.py) - EntityAssetEndpoint.get (space/views/asset.py) ATTACHMENT_MIME_TYPES is unchanged — users can still upload SVG, JS, and XML files. The fix closes the XSS vector by ensuring script-capable assets are always downloaded rather than rendered in the application's origin. Co-authored-by: Plane AI --- apps/api/plane/api/views/asset.py | 13 +++++++++++-- apps/api/plane/app/views/asset/v2.py | 13 +++++++++++-- apps/api/plane/settings/common.py | 15 +++++++++++++++ apps/api/plane/space/views/asset.py | 13 +++++++++++-- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/api/plane/api/views/asset.py b/apps/api/plane/api/views/asset.py index 7af1d13d24d..917ee97ce37 100644 --- a/apps/api/plane/api/views/asset.py +++ b/apps/api/plane/api/views/asset.py @@ -444,10 +444,19 @@ def get(self, request, slug, asset_id): status=status.HTTP_400_BAD_REQUEST, ) - # Generate presigned URL for GET + # Generate presigned URL for GET. + # Force attachment disposition for script-capable MIME types (e.g. SVG) + # to prevent same-origin XSS when the asset URL shares the app's origin + # (default MinIO self-hosted setup). storage = S3Storage(request=request, is_server=True) + asset_mime_type = asset.attributes.get("type", "") + disposition = ( + "attachment" if asset_mime_type in settings.SCRIPT_CAPABLE_MIME_TYPES else "inline" + ) presigned_url = storage.generate_presigned_url( - object_name=asset.asset.name, filename=asset.attributes.get("name") + object_name=asset.asset.name, + filename=asset.attributes.get("name"), + disposition=disposition, ) return Response( diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index b21f70d61fc..92d340cd13c 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -462,10 +462,19 @@ def get(self, request, asset_id): status=status.HTTP_400_BAD_REQUEST, ) - # Get the presigned URL + # Get the presigned URL. + # Force attachment disposition for script-capable MIME types to prevent + # same-origin XSS when assets are served on the application's origin. storage = S3Storage(request=request) + asset_mime_type = asset.attributes.get("type", "") + disposition = ( + "attachment" if asset_mime_type in settings.SCRIPT_CAPABLE_MIME_TYPES else "inline" + ) # Generate a presigned URL to share an S3 object - signed_url = storage.generate_presigned_url(object_name=asset.asset.name) + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition=disposition, + ) # Redirect to the signed URL return HttpResponseRedirect(signed_url) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index be444794f24..25a212e7639 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -544,6 +544,21 @@ def _retention_days(env_var, default): "text/markdown", ] +# MIME types that browsers can execute as scripts when served inline. +# These must always be served with Content-Disposition: attachment, even if they +# somehow end up stored (e.g. uploaded before this restriction was added). +SCRIPT_CAPABLE_MIME_TYPES: frozenset[str] = frozenset( + [ + "image/svg+xml", # SVG with onload / embedded