Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/api/plane/api/views/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 11 additions & 2 deletions apps/api/plane/app/views/asset/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script> tags
"text/javascript",
"application/javascript",
"text/html",
"application/xhtml+xml",
"text/xml",
"application/xml",
]
)

# Seed directory path
SEED_DIR = os.path.join(BASE_DIR, "seeds")

Expand Down
13 changes: 11 additions & 2 deletions apps/api/plane/space/views/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,19 @@ def get(self, request, anchor, pk):
status=status.HTTP_404_NOT_FOUND,
)

# Get the presigned URL
# Get the presigned URL.
# Force attachment disposition for script-capable MIME types to prevent
# same-origin XSS when Spaces 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)

Expand Down
Loading