Loading src/rpc/tests.py +53 −0 Original line number Diff line number Diff line Loading @@ -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 Loading src/rpc/views.py +30 −2 Original line number Diff line number Diff line Loading @@ -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 ( Loading @@ -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 Loading @@ -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 Loading Loading @@ -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 Loading Loading
src/rpc/tests.py +53 −0 Original line number Diff line number Diff line Loading @@ -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 Loading
src/rpc/views.py +30 −2 Original line number Diff line number Diff line Loading @@ -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 ( Loading @@ -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 Loading @@ -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 Loading Loading @@ -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 Loading