diff --git a/CHANGES.rst b/CHANGES.rst index fbe58dc133..b2d7a6d602 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,7 +10,12 @@ https://zope.readthedocs.io/en/2.13/CHANGES.html 4.8.10 (unreleased) ------------------- -- Nothing changed yet. +- Allow only some image types to be displayed inline. Force download for + others, especially SVG images. By default we use a list of allowed types. + You can switch a to a list of denied types by setting OS environment variable + ``OFS_IMAGE_USE_DENYLIST=1``. This change only affects direct URL access. + ```` works the same as before. + See `security advisory `_. 4.8.9 (2023-09-05) diff --git a/src/OFS/Image.py b/src/OFS/Image.py index 0e1caf53ef..72e1e6ab1e 100644 --- a/src/OFS/Image.py +++ b/src/OFS/Image.py @@ -13,6 +13,7 @@ """Image object """ +import os import struct from email.generator import _make_boundary from io import BytesIO @@ -23,6 +24,7 @@ from six import PY2 from six import binary_type from six import text_type +from six.moves.urllib.parse import quote import ZPublisher.HTTPRequest from AccessControl.class_init import InitializeClass @@ -61,6 +63,42 @@ from cgi import escape +ALLOWED_INLINE_MIMETYPES = [ + "image/gif", + # The mimetypes registry lists several for jpeg 2000: + "image/jp2", + "image/jpeg", + "image/jpeg2000-image", + "image/jpeg2000", + "image/jpx", + "image/png", + "image/webp", + "image/x-icon", + "image/x-jpeg2000-image", + "text/plain", + # By popular request we allow PDF: + "application/pdf", +] + +# Perhaps a denylist is better. +DISALLOWED_INLINE_MIMETYPES = [ + "application/javascript", + "application/x-javascript", + "text/javascript", + "text/html", + "image/svg+xml", + "image/svg+xml-compressed", +] + +# By default we use the allowlist. We give integrators the option to choose +# the denylist via an environment variable. +try: + USE_DENYLIST = os.environ.get("OFS_IMAGE_USE_DENYLIST") + USE_DENYLIST = bool(int(USE_DENYLIST)) +except (ValueError, TypeError, AttributeError): + USE_DENYLIST = False + + manage_addFileForm = DTMLFile( 'dtml/imageAdd', globals(), @@ -120,6 +158,13 @@ class File( Cacheable ): """A File object is a content object for arbitrary files.""" + # You can control which mimetypes may be shown inline + # and which must always be downloaded, for security reasons. + # Make the configuration available on the class. + # Then subclasses can override this. + allowed_inline_mimetypes = ALLOWED_INLINE_MIMETYPES + disallowed_inline_mimetypes = DISALLOWED_INLINE_MIMETYPES + use_denylist = USE_DENYLIST meta_type = 'File' zmi_icon = 'far fa-file-archive' @@ -418,6 +463,19 @@ def _range_request_handler(self, REQUEST, RESPONSE): b'\r\n--' + boundary.encode('ascii') + b'--\r\n') return True + def _should_force_download(self): + # If this returns True, the caller should set a + # Content-Disposition header with filename. + mimetype = self.content_type + if not mimetype: + return False + if self.use_denylist: + # We explicitly deny a few mimetypes, and allow the rest. + return mimetype in self.disallowed_inline_mimetypes + # Use the allowlist. + # We only explicitly allow a few mimetypes, and deny the rest. + return mimetype not in self.allowed_inline_mimetypes + @security.protected(View) def index_html(self, REQUEST, RESPONSE): """ @@ -456,6 +514,19 @@ def index_html(self, REQUEST, RESPONSE): RESPONSE.setHeader('Content-Length', self.size) RESPONSE.setHeader('Accept-Ranges', 'bytes') + if self._should_force_download(): + # We need a filename, even a dummy one if needed. + filename = self.getId() + if "." not in filename: + # image/svg+xml -> svg + ext = self.content_type.split("/")[-1].split("+")[0] + filename += "." + ext + filename = quote(filename.encode("utf8")) + RESPONSE.setHeader( + "Content-Disposition", + "attachment; filename*=UTF-8''{}".format(filename), + ) + if self.ZCacheable_isCachingEnabled(): result = self.ZCacheable_get(default=None) if result is not None: diff --git a/src/OFS/tests/testFileAndImage.py b/src/OFS/tests/testFileAndImage.py index 264834fcc9..cfefb4e73c 100644 --- a/src/OFS/tests/testFileAndImage.py +++ b/src/OFS/tests/testFileAndImage.py @@ -368,6 +368,7 @@ def testViewImageOrFile(self): response = request.RESPONSE result = self.file.index_html(request, response) self.assertEqual(result, self.data) + self.assertIsNone(response.getHeader("Content-Disposition")) def test_interfaces(self): from OFS.Image import Image @@ -382,6 +383,20 @@ def test_text_representation_is_tag(self): ' alt="" title="" height="16" width="16" />') +class SVGTests(ImageTests): + content_type = 'image/svg+xml' + + def testViewImageOrFile(self): + request = self.app.REQUEST + response = request.RESPONSE + result = self.file.index_html(request, response) + self.assertEqual(result, self.data) + self.assertEqual( + response.getHeader("Content-Disposition"), + "attachment; filename*=UTF-8''file.svg", + ) + + class FileEditTests(Testing.ZopeTestCase.FunctionalTestCase): """Browser testing ..Image.File"""