diff --git a/last_commit.txt b/last_commit.txt index 9541b16b75..1715aa18bf 100644 --- a/last_commit.txt +++ b/last_commit.txt @@ -1,39 +1,40 @@ -Repository: plone.rest +Repository: plone.namedfile Branch: refs/heads/master -Date: 2023-09-19T12:39:57+02:00 +Date: 2023-09-18T14:04:36+02:00 Author: Maurits van Rees (mauritsvanrees) -Commit: https://github.com/plone/plone.rest/commit/db0cb9adb7f483bffb3e1ab06d1270c7db0b04a8 +Commit: https://github.com/plone/plone.namedfile/commit/07a890a857ff14c6077fa5eabab42bdf99061162 -When ++api++ is in the url multiple times, redirect to the proper url. +Force download of image scales when inline is not allowed for this mimetype. -When the url is badly formed, for example `++api++/something/++api++`, give a 404 NotFound. -Fixes a denial of service. +We already did this for the `@@display-file` view in 2021, but now it is done for image scales as well. + +See [security advisory](https://github.com/plone/plone.namedfile/security/advisories/GHSA-jj7c-jrv4-c65x). Files changed: A news/1.bugfix -M src/plone/rest/tests/test_traversal.py -M src/plone/rest/traverse.py +M plone/namedfile/scaling.py +M plone/namedfile/tests/test_display_file.py -b'diff --git a/news/1.bugfix b/news/1.bugfix\nnew file mode 100644\nindex 0000000..019268d\n--- /dev/null\n+++ b/news/1.bugfix\n@@ -0,0 +1,5 @@\n+When ``++api++`` is in the url multiple times, redirect to the proper url.\n+When the url is badly formed, for example ``++api++/something/++api++``, give a 404 NotFound.\n+Fixes a denial of service.\n+See `security advisory `_.\n+[maurits]\ndiff --git a/src/plone/rest/tests/test_traversal.py b/src/plone/rest/tests/test_traversal.py\nindex 963d1c4..5d5389d 100644\n--- a/src/plone/rest/tests/test_traversal.py\n+++ b/src/plone/rest/tests/test_traversal.py\n@@ -10,6 +10,8 @@\n from plone.app.testing import TEST_USER_ID\n from plone.rest.service import Service\n from plone.rest.testing import PLONE_REST_INTEGRATION_TESTING\n+from zExceptions import NotFound\n+from zExceptions import Redirect\n from zope.event import notify\n from zope.interface import alsoProvides\n from zope.publisher.interfaces.browser import IBrowserView\n@@ -106,6 +108,34 @@ def test_html_request_on_existing_view_returns_view(self):\n obj = self.traverse(path="/plone/folder1/search", accept="text/html")\n self.assertFalse(isinstance(obj, Service), "Got a service")\n \n+ def test_html_request_via_api_returns_service(self):\n+ obj = self.traverse(path="/plone/++api++", accept="text/html")\n+ self.assertTrue(isinstance(obj, Service), "Not a service")\n+\n+ def test_html_request_via_double_apis_raises_redirect(self):\n+ portal_url = self.portal.absolute_url()\n+ with self.assertRaises(Redirect) as exc:\n+ self.traverse(path="/plone/++api++/++api++", accept="text/html")\n+ self.assertEqual(\n+ exc.exception.headers["Location"],\n+ f"{portal_url}/++api++",\n+ )\n+\n+ def test_html_request_via_multiple_apis_raises_redirect(self):\n+ portal_url = self.portal.absolute_url()\n+ with self.assertRaises(Redirect) as exc:\n+ self.traverse(\n+ path="/plone/++api++/++api++/++api++/search", accept="text/html"\n+ )\n+ self.assertEqual(\n+ exc.exception.headers["Location"],\n+ f"{portal_url}/++api++/search",\n+ )\n+\n+ def test_html_request_via_multiple_bad_apis_raises_not_found(self):\n+ with self.assertRaises(NotFound):\n+ self.traverse(path="/plone/++api++/search/++api++", accept="text/html")\n+\n def test_virtual_hosting(self):\n app = self.layer["app"]\n vhm = VirtualHostMonster()\ndiff --git a/src/plone/rest/traverse.py b/src/plone/rest/traverse.py\nindex f8d4a23..0a151c8 100644\n--- a/src/plone/rest/traverse.py\n+++ b/src/plone/rest/traverse.py\n@@ -5,6 +5,7 @@\n from plone.rest.interfaces import IAPIRequest\n from plone.rest.interfaces import IService\n from plone.rest.events import mark_as_api_request\n+from zExceptions import Redirect\n from zope.component import adapter\n from zope.component import queryMultiAdapter\n from zope.interface import implementer\n@@ -64,6 +65,18 @@ def __init__(self, context, request):\n self.request = request\n \n def traverse(self, name_ignored, subpath_ignored):\n+ name = "/++api++"\n+ url = self.request.ACTUAL_URL\n+ if url.count(name) > 1:\n+ # Redirect to proper url.\n+ while f"{name}{name}" in url:\n+ url = url.replace(f"{name}{name}", name)\n+ if url.count(name) > 1:\n+ # Something like: .../++api++/something/++api++\n+ # Return nothing, so a NotFound is raised.\n+ return\n+ # Raise a redirect exception to stop execution of the current request.\n+ raise Redirect(url)\n mark_as_api_request(self.request, "application/json")\n return self.context\n \n' +b'diff --git a/news/1.bugfix b/news/1.bugfix\nnew file mode 100644\nindex 0000000..c6cf8bb\n--- /dev/null\n+++ b/news/1.bugfix\n@@ -0,0 +1,4 @@\n+Fix stored XSS (Cross Site Scripting) for SVG images.\n+Done by forcing a download instead of displaying inline.\n+See `security advisory `_.\n+[maurits]\ndiff --git a/plone/namedfile/scaling.py b/plone/namedfile/scaling.py\nindex ffa064d..47503dd 100644\n--- a/plone/namedfile/scaling.py\n+++ b/plone/namedfile/scaling.py\n@@ -4,6 +4,9 @@\n from io import BytesIO\n from plone.memoize import ram\n from plone.namedfile.file import FILECHUNK_CLASSES\n+from plone.namedfile.browser import ALLOWED_INLINE_MIMETYPES\n+from plone.namedfile.browser import DISALLOWED_INLINE_MIMETYPES\n+from plone.namedfile.browser import USE_DENYLIST\n from plone.namedfile.interfaces import IAvailableSizes\n from plone.namedfile.interfaces import IStableImageScale\n from plone.namedfile.picture import get_picture_variants\n@@ -75,6 +78,14 @@ class ImageScale(BrowserView):\n __allow_access_to_unprotected_subobjects__ = 1\n data = None\n \n+ # You can control which mimetypes may be shown inline\n+ # and which must always be downloaded, for security reasons.\n+ # Make the configuration available on the class.\n+ # Then subclasses can override this.\n+ allowed_inline_mimetypes = ALLOWED_INLINE_MIMETYPES\n+ disallowed_inline_mimetypes = DISALLOWED_INLINE_MIMETYPES\n+ use_denylist = USE_DENYLIST\n+\n def __init__(self, context, request, **info):\n self.context = context\n self.request = request\n@@ -167,10 +178,37 @@ def validate_access(self):\n fieldname = getattr(self.data, "fieldname", getattr(self, "fieldname", None))\n guarded_getattr(self.context, fieldname)\n \n+ def _should_force_download(self):\n+ # If this returns True, the caller should call set_headers with a filename.\n+ if not hasattr(self.data, "contentType"):\n+ return\n+ mimetype = self.data.contentType\n+ if self.use_denylist:\n+ # We explicitly deny a few mimetypes, and allow the rest.\n+ return mimetype in self.disallowed_inline_mimetypes\n+ # Use the allowlist.\n+ # We only explicitly allow a few mimetypes, and deny the rest.\n+ return mimetype not in self.allowed_inline_mimetypes\n+\n+ def set_headers(self, response=None):\n+ # set headers for the image\n+ image = self.data\n+ if response is None:\n+ response = self.request.response\n+ filename = None\n+ if self._should_force_download():\n+ # We MUST pass a filename, even a dummy one if needed.\n+ filename = getattr(image, "filename", getattr(self, "filename", None))\n+ if filename is None:\n+ filename = getattr(image, "fieldname", getattr(self, "fieldname", None))\n+ if filename is None:\n+ filename = "image.ext"\n+ set_headers(image, response, filename=filename)\n+\n def index_html(self):\n """download the image"""\n self.validate_access()\n- set_headers(self.data, self.request.response)\n+ self.set_headers()\n return stream_data(self.data)\n \n def manage_DAVget(self):\n@@ -190,7 +228,7 @@ def HEAD(self, REQUEST, RESPONSE=None):\n without transfer of the image itself\n """\n self.validate_access()\n- set_headers(self.data, REQUEST.response)\n+ self.set_headers(response=REQUEST.response)\n return ""\n \n HEAD.__roles__ = ("Anonymous",)\ndiff --git a/plone/namedfile/tests/test_display_file.py b/plone/namedfile/tests/test_display_file.py\nindex 86251a8..4ab467d 100644\n--- a/plone/namedfile/tests/test_display_file.py\n+++ b/plone/namedfile/tests/test_display_file.py\n@@ -3,12 +3,15 @@\n from plone.app.testing import SITE_OWNER_PASSWORD\n from plone.namedfile import field\n from plone.namedfile import file\n+from plone.namedfile.interfaces import IAvailableSizes\n from plone.namedfile.interfaces import IImageScaleTraversable\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.tests import getFile\n from plone.testing.zope import Browser\n from Products.CMFPlone.utils import safe_unicode\n+from zope.annotation import IAnnotations\n from zope.annotation import IAttributeAnnotatable\n+from zope.component import getSiteManager\n from zope.interface import implementer\n \n import transaction\n@@ -46,6 +49,11 @@ def get_disposition_header(browser):\n return browser.headers.get(name, None)\n \n \n+def custom_available_sizes():\n+ # Define available image scales.\n+ return {"custom": (10, 10)}\n+\n+\n class TestAttackVectorNamedImage(unittest.TestCase):\n layer = PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n field_class = file.NamedImage\n@@ -56,6 +64,12 @@ def setUp(self):\n item = DummyContent()\n self.layer["app"]._setOb("item", item)\n self.item = self.layer["app"].item\n+ sm = getSiteManager()\n+ sm.registerUtility(component=custom_available_sizes, provided=IAvailableSizes)\n+\n+ def tearDown(self):\n+ sm = getSiteManager()\n+ sm.unregisterUtility(provided=IAvailableSizes)\n \n def get_admin_browser(self):\n browser = Browser(self.layer["app"])\n@@ -98,12 +112,50 @@ def assert_display_inline_is_download(self, base_url):\n self.assertIn("attachment", header)\n self.assertIn("filename", header)\n \n+ def assert_scale_view_works(self, base_url):\n+ # Test that accessing a scale view shows the image inline.\n+ browser = self.get_anon_browser()\n+ browser.open(base_url + "/@@images/{}".format(self.field_name))\n+ self.assertIsNone(get_disposition_header(browser))\n+\n+ # Note: the \'custom\' scale is defined in an adapter above.\n+ browser.open(base_url + "/@@images/{}/custom".format(self.field_name))\n+ self.assertIsNone(get_disposition_header(browser))\n+\n+ unique_scale_id = list(IAnnotations(self.item)["plone.scale"].keys())[0]\n+ browser.open(base_url + "/@@images/{}".format(unique_scale_id))\n+ self.assertIsNone(get_disposition_header(browser))\n+\n+ def assert_scale_view_is_download(self, base_url):\n+ # Test that accessing a scale view turns into a download.\n+ browser = self.get_anon_browser()\n+ browser.open(base_url + "/@@images/{}".format(self.field_name))\n+ header = get_disposition_header(browser)\n+ self.assertIsNotNone(header)\n+ self.assertIn("attachment", header)\n+ self.assertIn("filename", header)\n+\n+ browser.open(base_url + "/@@images/{}/custom".format(self.field_name))\n+ header = get_disposition_header(browser)\n+ self.assertIsNotNone(header)\n+ self.assertIn("attachment", header)\n+ self.assertIn("filename", header)\n+\n+ unique_scale_id = list(IAnnotations(self.item)["plone.scale"].keys())[0]\n+ browser.open(base_url + "/@@images/{}".format(unique_scale_id))\n+ header = get_disposition_header(browser)\n+ self.assertIsNotNone(header)\n+ self.assertIn("attachment", header)\n+ self.assertIn("filename", header)\n+\n def test_png_image(self):\n setattr(self.item, self.field_name, self._named_file("image.png"))\n transaction.commit()\n base_url = self.item.absolute_url()\n self.assert_download_works(base_url)\n self.assert_display_inline_works(base_url)\n+ if self.field_name == "image":\n+ self.assert_scale_view_works(base_url)\n \n def test_svg_image(self):\n setattr(self.item, self.field_name, self._named_file("image.svg"))\n@@ -111,10 +163,13 @@ def test_svg_image(self):\n base_url = self.item.absolute_url()\n self.assert_download_works(base_url)\n self.assert_display_inline_is_download(base_url)\n+ if self.field_name == "image":\n+ self.assert_scale_view_is_download(base_url)\n \n def test_filename_none(self):\n- # A \'None\' filename None probably does not happen during normal upload,\n- # but if an attacker manages this, even @@download will show inline.\n+ # A \'None\' filename probably does not happen during normal upload,\n+ # but if an attacker manages this, even @@download would show inline.\n+ # We prevent this.\n data = self._named_file("image.svg")\n data.filename = None\n setattr(self.item, self.field_name, data)\n@@ -122,6 +177,8 @@ def test_filename_none(self):\n base_url = self.item.absolute_url()\n self.assert_download_works(base_url)\n self.assert_display_inline_is_download(base_url)\n+ if self.field_name == "image":\n+ self.assert_scale_view_is_download(base_url)\n \n def test_filename_empty(self):\n # An empty filename is probably no problem, but let\'s check.\n@@ -132,6 +189,8 @@ def test_filename_empty(self):\n base_url = self.item.absolute_url()\n self.assert_download_works(base_url)\n self.assert_display_inline_is_download(base_url)\n+ if self.field_name == "image":\n+ self.assert_scale_view_is_download(base_url)\n \n \n class TestAttackVectorNamedBlobImage(TestAttackVectorNamedImage):\n' -Repository: plone.rest +Repository: plone.namedfile Branch: refs/heads/master -Date: 2023-09-21T13:15:43+02:00 +Date: 2023-09-21T13:22:31+02:00 Author: Maurits van Rees (mauritsvanrees) -Commit: https://github.com/plone/plone.rest/commit/77846a9842889b24f35e8bedc2e9d461388d3302 +Commit: https://github.com/plone/plone.namedfile/commit/f0f911f2a72b2e5c923dc2ab9179319cc47788f9 -Merge pull request from GHSA-h6rp-mprm-xgcq +Merge pull request from GHSA-jj7c-jrv4-c65x -When ++api++ is in the url multiple times, redirect to the proper url. [master] +Force download of image scales when inline is not allowed for mime type [master] Files changed: A news/1.bugfix -M src/plone/rest/tests/test_traversal.py -M src/plone/rest/traverse.py +M plone/namedfile/scaling.py +M plone/namedfile/tests/test_display_file.py -b'diff --git a/news/1.bugfix b/news/1.bugfix\nnew file mode 100644\nindex 0000000..019268d\n--- /dev/null\n+++ b/news/1.bugfix\n@@ -0,0 +1,5 @@\n+When ``++api++`` is in the url multiple times, redirect to the proper url.\n+When the url is badly formed, for example ``++api++/something/++api++``, give a 404 NotFound.\n+Fixes a denial of service.\n+See `security advisory `_.\n+[maurits]\ndiff --git a/src/plone/rest/tests/test_traversal.py b/src/plone/rest/tests/test_traversal.py\nindex 963d1c4..5d5389d 100644\n--- a/src/plone/rest/tests/test_traversal.py\n+++ b/src/plone/rest/tests/test_traversal.py\n@@ -10,6 +10,8 @@\n from plone.app.testing import TEST_USER_ID\n from plone.rest.service import Service\n from plone.rest.testing import PLONE_REST_INTEGRATION_TESTING\n+from zExceptions import NotFound\n+from zExceptions import Redirect\n from zope.event import notify\n from zope.interface import alsoProvides\n from zope.publisher.interfaces.browser import IBrowserView\n@@ -106,6 +108,34 @@ def test_html_request_on_existing_view_returns_view(self):\n obj = self.traverse(path="/plone/folder1/search", accept="text/html")\n self.assertFalse(isinstance(obj, Service), "Got a service")\n \n+ def test_html_request_via_api_returns_service(self):\n+ obj = self.traverse(path="/plone/++api++", accept="text/html")\n+ self.assertTrue(isinstance(obj, Service), "Not a service")\n+\n+ def test_html_request_via_double_apis_raises_redirect(self):\n+ portal_url = self.portal.absolute_url()\n+ with self.assertRaises(Redirect) as exc:\n+ self.traverse(path="/plone/++api++/++api++", accept="text/html")\n+ self.assertEqual(\n+ exc.exception.headers["Location"],\n+ f"{portal_url}/++api++",\n+ )\n+\n+ def test_html_request_via_multiple_apis_raises_redirect(self):\n+ portal_url = self.portal.absolute_url()\n+ with self.assertRaises(Redirect) as exc:\n+ self.traverse(\n+ path="/plone/++api++/++api++/++api++/search", accept="text/html"\n+ )\n+ self.assertEqual(\n+ exc.exception.headers["Location"],\n+ f"{portal_url}/++api++/search",\n+ )\n+\n+ def test_html_request_via_multiple_bad_apis_raises_not_found(self):\n+ with self.assertRaises(NotFound):\n+ self.traverse(path="/plone/++api++/search/++api++", accept="text/html")\n+\n def test_virtual_hosting(self):\n app = self.layer["app"]\n vhm = VirtualHostMonster()\ndiff --git a/src/plone/rest/traverse.py b/src/plone/rest/traverse.py\nindex f8d4a23..0a151c8 100644\n--- a/src/plone/rest/traverse.py\n+++ b/src/plone/rest/traverse.py\n@@ -5,6 +5,7 @@\n from plone.rest.interfaces import IAPIRequest\n from plone.rest.interfaces import IService\n from plone.rest.events import mark_as_api_request\n+from zExceptions import Redirect\n from zope.component import adapter\n from zope.component import queryMultiAdapter\n from zope.interface import implementer\n@@ -64,6 +65,18 @@ def __init__(self, context, request):\n self.request = request\n \n def traverse(self, name_ignored, subpath_ignored):\n+ name = "/++api++"\n+ url = self.request.ACTUAL_URL\n+ if url.count(name) > 1:\n+ # Redirect to proper url.\n+ while f"{name}{name}" in url:\n+ url = url.replace(f"{name}{name}", name)\n+ if url.count(name) > 1:\n+ # Something like: .../++api++/something/++api++\n+ # Return nothing, so a NotFound is raised.\n+ return\n+ # Raise a redirect exception to stop execution of the current request.\n+ raise Redirect(url)\n mark_as_api_request(self.request, "application/json")\n return self.context\n \n' +b'diff --git a/news/1.bugfix b/news/1.bugfix\nnew file mode 100644\nindex 0000000..c6cf8bb\n--- /dev/null\n+++ b/news/1.bugfix\n@@ -0,0 +1,4 @@\n+Fix stored XSS (Cross Site Scripting) for SVG images.\n+Done by forcing a download instead of displaying inline.\n+See `security advisory `_.\n+[maurits]\ndiff --git a/plone/namedfile/scaling.py b/plone/namedfile/scaling.py\nindex ffa064d..47503dd 100644\n--- a/plone/namedfile/scaling.py\n+++ b/plone/namedfile/scaling.py\n@@ -4,6 +4,9 @@\n from io import BytesIO\n from plone.memoize import ram\n from plone.namedfile.file import FILECHUNK_CLASSES\n+from plone.namedfile.browser import ALLOWED_INLINE_MIMETYPES\n+from plone.namedfile.browser import DISALLOWED_INLINE_MIMETYPES\n+from plone.namedfile.browser import USE_DENYLIST\n from plone.namedfile.interfaces import IAvailableSizes\n from plone.namedfile.interfaces import IStableImageScale\n from plone.namedfile.picture import get_picture_variants\n@@ -75,6 +78,14 @@ class ImageScale(BrowserView):\n __allow_access_to_unprotected_subobjects__ = 1\n data = None\n \n+ # You can control which mimetypes may be shown inline\n+ # and which must always be downloaded, for security reasons.\n+ # Make the configuration available on the class.\n+ # Then subclasses can override this.\n+ allowed_inline_mimetypes = ALLOWED_INLINE_MIMETYPES\n+ disallowed_inline_mimetypes = DISALLOWED_INLINE_MIMETYPES\n+ use_denylist = USE_DENYLIST\n+\n def __init__(self, context, request, **info):\n self.context = context\n self.request = request\n@@ -167,10 +178,37 @@ def validate_access(self):\n fieldname = getattr(self.data, "fieldname", getattr(self, "fieldname", None))\n guarded_getattr(self.context, fieldname)\n \n+ def _should_force_download(self):\n+ # If this returns True, the caller should call set_headers with a filename.\n+ if not hasattr(self.data, "contentType"):\n+ return\n+ mimetype = self.data.contentType\n+ if self.use_denylist:\n+ # We explicitly deny a few mimetypes, and allow the rest.\n+ return mimetype in self.disallowed_inline_mimetypes\n+ # Use the allowlist.\n+ # We only explicitly allow a few mimetypes, and deny the rest.\n+ return mimetype not in self.allowed_inline_mimetypes\n+\n+ def set_headers(self, response=None):\n+ # set headers for the image\n+ image = self.data\n+ if response is None:\n+ response = self.request.response\n+ filename = None\n+ if self._should_force_download():\n+ # We MUST pass a filename, even a dummy one if needed.\n+ filename = getattr(image, "filename", getattr(self, "filename", None))\n+ if filename is None:\n+ filename = getattr(image, "fieldname", getattr(self, "fieldname", None))\n+ if filename is None:\n+ filename = "image.ext"\n+ set_headers(image, response, filename=filename)\n+\n def index_html(self):\n """download the image"""\n self.validate_access()\n- set_headers(self.data, self.request.response)\n+ self.set_headers()\n return stream_data(self.data)\n \n def manage_DAVget(self):\n@@ -190,7 +228,7 @@ def HEAD(self, REQUEST, RESPONSE=None):\n without transfer of the image itself\n """\n self.validate_access()\n- set_headers(self.data, REQUEST.response)\n+ self.set_headers(response=REQUEST.response)\n return ""\n \n HEAD.__roles__ = ("Anonymous",)\ndiff --git a/plone/namedfile/tests/test_display_file.py b/plone/namedfile/tests/test_display_file.py\nindex 86251a8..4ab467d 100644\n--- a/plone/namedfile/tests/test_display_file.py\n+++ b/plone/namedfile/tests/test_display_file.py\n@@ -3,12 +3,15 @@\n from plone.app.testing import SITE_OWNER_PASSWORD\n from plone.namedfile import field\n from plone.namedfile import file\n+from plone.namedfile.interfaces import IAvailableSizes\n from plone.namedfile.interfaces import IImageScaleTraversable\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.tests import getFile\n from plone.testing.zope import Browser\n from Products.CMFPlone.utils import safe_unicode\n+from zope.annotation import IAnnotations\n from zope.annotation import IAttributeAnnotatable\n+from zope.component import getSiteManager\n from zope.interface import implementer\n \n import transaction\n@@ -46,6 +49,11 @@ def get_disposition_header(browser):\n return browser.headers.get(name, None)\n \n \n+def custom_available_sizes():\n+ # Define available image scales.\n+ return {"custom": (10, 10)}\n+\n+\n class TestAttackVectorNamedImage(unittest.TestCase):\n layer = PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n field_class = file.NamedImage\n@@ -56,6 +64,12 @@ def setUp(self):\n item = DummyContent()\n self.layer["app"]._setOb("item", item)\n self.item = self.layer["app"].item\n+ sm = getSiteManager()\n+ sm.registerUtility(component=custom_available_sizes, provided=IAvailableSizes)\n+\n+ def tearDown(self):\n+ sm = getSiteManager()\n+ sm.unregisterUtility(provided=IAvailableSizes)\n \n def get_admin_browser(self):\n browser = Browser(self.layer["app"])\n@@ -98,12 +112,50 @@ def assert_display_inline_is_download(self, base_url):\n self.assertIn("attachment", header)\n self.assertIn("filename", header)\n \n+ def assert_scale_view_works(self, base_url):\n+ # Test that accessing a scale view shows the image inline.\n+ browser = self.get_anon_browser()\n+ browser.open(base_url + "/@@images/{}".format(self.field_name))\n+ self.assertIsNone(get_disposition_header(browser))\n+\n+ # Note: the \'custom\' scale is defined in an adapter above.\n+ browser.open(base_url + "/@@images/{}/custom".format(self.field_name))\n+ self.assertIsNone(get_disposition_header(browser))\n+\n+ unique_scale_id = list(IAnnotations(self.item)["plone.scale"].keys())[0]\n+ browser.open(base_url + "/@@images/{}".format(unique_scale_id))\n+ self.assertIsNone(get_disposition_header(browser))\n+\n+ def assert_scale_view_is_download(self, base_url):\n+ # Test that accessing a scale view turns into a download.\n+ browser = self.get_anon_browser()\n+ browser.open(base_url + "/@@images/{}".format(self.field_name))\n+ header = get_disposition_header(browser)\n+ self.assertIsNotNone(header)\n+ self.assertIn("attachment", header)\n+ self.assertIn("filename", header)\n+\n+ browser.open(base_url + "/@@images/{}/custom".format(self.field_name))\n+ header = get_disposition_header(browser)\n+ self.assertIsNotNone(header)\n+ self.assertIn("attachment", header)\n+ self.assertIn("filename", header)\n+\n+ unique_scale_id = list(IAnnotations(self.item)["plone.scale"].keys())[0]\n+ browser.open(base_url + "/@@images/{}".format(unique_scale_id))\n+ header = get_disposition_header(browser)\n+ self.assertIsNotNone(header)\n+ self.assertIn("attachment", header)\n+ self.assertIn("filename", header)\n+\n def test_png_image(self):\n setattr(self.item, self.field_name, self._named_file("image.png"))\n transaction.commit()\n base_url = self.item.absolute_url()\n self.assert_download_works(base_url)\n self.assert_display_inline_works(base_url)\n+ if self.field_name == "image":\n+ self.assert_scale_view_works(base_url)\n \n def test_svg_image(self):\n setattr(self.item, self.field_name, self._named_file("image.svg"))\n@@ -111,10 +163,13 @@ def test_svg_image(self):\n base_url = self.item.absolute_url()\n self.assert_download_works(base_url)\n self.assert_display_inline_is_download(base_url)\n+ if self.field_name == "image":\n+ self.assert_scale_view_is_download(base_url)\n \n def test_filename_none(self):\n- # A \'None\' filename None probably does not happen during normal upload,\n- # but if an attacker manages this, even @@download will show inline.\n+ # A \'None\' filename probably does not happen during normal upload,\n+ # but if an attacker manages this, even @@download would show inline.\n+ # We prevent this.\n data = self._named_file("image.svg")\n data.filename = None\n setattr(self.item, self.field_name, data)\n@@ -122,6 +177,8 @@ def test_filename_none(self):\n base_url = self.item.absolute_url()\n self.assert_download_works(base_url)\n self.assert_display_inline_is_download(base_url)\n+ if self.field_name == "image":\n+ self.assert_scale_view_is_download(base_url)\n \n def test_filename_empty(self):\n # An empty filename is probably no problem, but let\'s check.\n@@ -132,6 +189,8 @@ def test_filename_empty(self):\n base_url = self.item.absolute_url()\n self.assert_download_works(base_url)\n self.assert_display_inline_is_download(base_url)\n+ if self.field_name == "image":\n+ self.assert_scale_view_is_download(base_url)\n \n \n class TestAttackVectorNamedBlobImage(TestAttackVectorNamedImage):\n'