diff --git a/apps/api/plane/api/views/asset.py b/apps/api/plane/api/views/asset.py index 7af1d13d24d..abfa6bdc0d8 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") or "").split(";")[0].strip().lower() + 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..c2fa891e54e 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") or "").split(";")[0].strip().lower() + 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