From 920126835a671cf49f38f0ab61beca8f436bb57f Mon Sep 17 00:00:00 2001 From: Semih702 Date: Wed, 5 Aug 2026 11:11:24 +0300 Subject: [PATCH] feat(api): allow text/html attachments text/html and application/xhtml+xml were missing from ATTACHMENT_MIME_TYPES, so uploading an .html file to a work item failed with "Invalid file type." HTML reports (Playwright, Jest, Lighthouse, JaCoCo) are a common thing to attach, and the workaround so far has been to zip the file or rename it to .txt, which loses the extension. Both types are already members of SCRIPT_CAPABLE_MIME_TYPES, so every asset download path already pins them to Content-Disposition: attachment (#9312 / GHSA-ch8j-vr4r-qf6h). The browser downloads them rather than rendering them, which is the same protection image/svg+xml has had since that fix. Closes #9543 --- apps/api/plane/settings/common.py | 5 + .../test_issue_attachment_mime_types_app.py | 118 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 apps/api/plane/tests/contract/app/test_issue_attachment_mime_types_app.py diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 25a212e7639..cbef024d3fa 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -532,6 +532,11 @@ def _retention_days(env_var, default): # Other "text/css", "text/javascript", + # Script-capable markup. Safe to accept because every download path serves + # SCRIPT_CAPABLE_MIME_TYPES with Content-Disposition: attachment, so these + # are never rendered inline on the application's origin. + "text/html", + "application/xhtml+xml", "application/json", "text/xml", "text/csv", diff --git a/apps/api/plane/tests/contract/app/test_issue_attachment_mime_types_app.py b/apps/api/plane/tests/contract/app/test_issue_attachment_mime_types_app.py new file mode 100644 index 00000000000..eb70cc467dc --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_issue_attachment_mime_types_app.py @@ -0,0 +1,118 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for the attachment MIME type allowlist on work items. + +``text/html`` and ``application/xhtml+xml`` are accepted for upload. They stay +safe because they are members of ``SCRIPT_CAPABLE_MIME_TYPES``, which pins every +asset download path to ``Content-Disposition: attachment`` (#9312 / +GHSA-ch8j-vr4r-qf6h), so the browser never renders them inline on the +application's origin. +""" + +from unittest import mock + +import pytest +from django.conf import settings +from rest_framework import status + +from plane.db.models import FileAsset, Issue, Project, ProjectMember + +HTML_MIME_TYPES = ["text/html", "application/xhtml+xml"] + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + return project + + +@pytest.fixture +def issue(db, workspace, project, create_user): + return Issue.objects.create( + name="Test Work Item", + workspace=workspace, + project=project, + created_by=create_user, + ) + + +@pytest.mark.contract +class TestIssueAttachmentMimeTypes: + def list_url(self, workspace, project, issue): + return f"/api/assets/v2/workspaces/{workspace.slug}/projects/{project.id}/issues/{issue.id}/attachments/" + + def detail_url(self, workspace, project, issue, asset_id): + return f"{self.list_url(workspace, project, issue)}{asset_id}/" + + @pytest.mark.django_db + @pytest.mark.parametrize("mime_type", HTML_MIME_TYPES) + def test_html_upload_is_accepted(self, session_client, workspace, project, issue, mime_type): + """An HTML attachment must be allowed instead of rejected as an invalid file type.""" + url = self.list_url(workspace, project, issue) + payload = {"name": "report.html", "type": mime_type, "size": 1024} + + with mock.patch("plane.app.views.issue.attachment.S3Storage") as mock_storage: + mock_storage.return_value.generate_presigned_post.return_value = {"url": "x", "fields": {}} + response = session_client.post(url, payload, format="json") + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + asset = FileAsset.objects.get(id=response.data["asset_id"]) + assert asset.attributes["type"] == mime_type + + @pytest.mark.django_db + def test_unlisted_mime_type_is_still_rejected(self, session_client, workspace, project, issue): + """Negative control: the allowlist still turns away types outside it.""" + url = self.list_url(workspace, project, issue) + payload = {"name": "payload.hta", "type": "application/hta", "size": 1024} + + response = session_client.post(url, payload, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST, f"Got {response.status_code}: {response.data!r}" + assert FileAsset.objects.filter(issue=issue).count() == 0 + + @pytest.mark.django_db + @pytest.mark.parametrize("mime_type", HTML_MIME_TYPES) + def test_html_download_is_served_as_an_attachment( + self, session_client, workspace, project, issue, create_user, mime_type + ): + """HTML must be handed out as a download, never rendered inline.""" + asset = FileAsset.objects.create( + attributes={"name": "report.html", "type": mime_type, "size": 1024}, + asset=f"{workspace.id}/report.html", + size=1024, + workspace=workspace, + project=project, + issue=issue, + created_by=create_user, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_uploaded=True, + storage_metadata={"size": 1024}, + ) + url = self.detail_url(workspace, project, issue, asset.id) + + with mock.patch("plane.app.views.issue.attachment.S3Storage") as mock_storage: + mock_storage.return_value.generate_presigned_url.return_value = "https://signed.example/download" + response = session_client.get(url) + + assert response.status_code == status.HTTP_302_FOUND, f"Got {response.status_code}" + call_kwargs = mock_storage.return_value.generate_presigned_url.call_args[1] + assert call_kwargs["disposition"] == "attachment" + + @pytest.mark.parametrize("mime_type", HTML_MIME_TYPES) + def test_html_is_script_capable(self, mime_type): + """The upload allowlist entry is only safe while the type stays script-capable. + + ``SCRIPT_CAPABLE_MIME_TYPES`` is what makes the asset download endpoints + choose ``attachment`` over ``inline``; dropping a type from it while it + remains uploadable would reopen the stored-XSS path closed by #9312. + """ + assert mime_type in settings.ATTACHMENT_MIME_TYPES + assert mime_type in settings.SCRIPT_CAPABLE_MIME_TYPES