Commit 89814b8a authored by Mickaël Desfrênes's avatar Mickaël Desfrênes
Browse files

restrict /rpc/serve/ inline rendering for dangerous file types

parent 177003da
Loading
Loading
Loading
Loading
+53 −0
Original line number Diff line number Diff line
@@ -218,6 +218,59 @@ class ServiceTestCase(TestCase):
                    response = views.serve_file_public(request, file_instance.hash)
                    self.assertEqual(response.status_code, 200)

    def test_public_serve_keeps_safe_media_inline(self):
        file_instance = self._public_download_test_file()
        request = self.factory.get(f"/rpc/serve/{file_instance.hash}")

        with tempfile.NamedTemporaryFile() as tmp:
            with (
                patch.object(views.settings, "JAMA_ENABLE_PUBLIC_DOWNLOAD_URLS", True),
                patch.object(models.File, "local_path", return_value=tmp.name),
            ):
                response = views.serve_file_public(request, file_instance.hash)

        self.assertTrue(response["Content-Disposition"].startswith("inline;"))
        self.assertNotIn("Content-Security-Policy", response)
        response.close()

    def test_public_serve_forces_dangerous_types_to_download(self):
        dangerous_types = (
            ("text/html", "html"),
            ("application/javascript", "js"),
            ("image/svg+xml", "svg"),
            ("application/xml", "xml"),
        )
        request = self.factory.get("/rpc/serve/dangerous")

        with tempfile.NamedTemporaryFile() as tmp:
            for index, (mime_type, extension) in enumerate(dangerous_types):
                with self.subTest(mime_type=mime_type):
                    file_type = models.FileType.objects.get(mime=mime_type)
                    file_instance = models.File.objects.create(
                        title=f"dangerous-{index}",
                        original_name=f"dangerous-{index}.{extension}",
                        project=self.test_project,
                        hash=str(index) * 64,
                        file_type=file_type,
                        size=0,
                    )
                    with (
                        patch.object(
                            views.settings, "JAMA_ENABLE_PUBLIC_DOWNLOAD_URLS", True
                        ),
                        patch.object(models.File, "local_path", return_value=tmp.name),
                    ):
                        response = views.serve_file_public(request, file_instance.hash)

                    self.assertTrue(
                        response["Content-Disposition"].startswith("attachment;")
                    )
                    self.assertEqual(
                        response["Content-Security-Policy"],
                        "sandbox; default-src 'none'",
                    )
                    response.close()

    def test_file_serializer_omits_public_urls_when_disabled(self):
        with patch.object(
            serializers.settings, "JAMA_ENABLE_PUBLIC_DOWNLOAD_URLS", False
+30 −2
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@ from shutil import rmtree
import base64
from .fileresponse import RangedFileResponse
from django.views.decorators.gzip import gzip_page
from django.utils.http import content_disposition_header
import logging
from functools import lru_cache
from resources.tasks import (
@@ -53,6 +54,20 @@ importlib = __import__("importlib")

logger = logging.getLogger(__name__)

DANGEROUS_INLINE_MIME_TYPES = frozenset(
    {
        "application/ecmascript",
        "application/javascript",
        "application/x-javascript",
        "application/xml",
        "text/ecmascript",
        "text/html",
        "text/javascript",
        "text/x-javascript",
        "text/xml",
    }
)


class UploadLimitExceeded(ValueError):
    pass
@@ -70,6 +85,11 @@ class BatchTooLarge(ValueError):
    pass


def _requires_attachment_for_inline_serve(content_type: str) -> bool:
    mime_type = content_type.partition(";")[0].strip().lower()
    return mime_type in DANGEROUS_INLINE_MIME_TYPES or mime_type.endswith("+xml")


@dataclass(frozen=True)
class UploadRequestContext:
    user: User
@@ -858,12 +878,20 @@ def serve_file_public(
    file = models.File.objects.filter(hash=file_hash).first()
    if not file:
        return HttpResponse("Not Found", status=404)
    return RangedFileResponse(
    requires_attachment = _requires_attachment_for_inline_serve(file.file_type.mime)
    response = RangedFileResponse(
        request,
        open(file.local_path(), "rb"),
        filename=file.new_filename,
        content_type=file.file_type.mime,  # Firefox is bad
        content_type=file.file_type.mime,
        as_attachment=requires_attachment,
    )
    response["Content-Disposition"] = content_disposition_header(
        requires_attachment, file.new_filename
    )
    if requires_attachment:
        response["Content-Security-Policy"] = "sandbox; default-src 'none'"
    return response


@csrf_exempt