|
13 | 13 | """Image object |
14 | 14 | """ |
15 | 15 |
|
| 16 | +import os |
16 | 17 | import struct |
17 | 18 | from email.generator import _make_boundary |
18 | 19 | from io import BytesIO |
19 | 20 | from io import TextIOBase |
| 21 | +from mimetypes import guess_extension |
20 | 22 | from tempfile import TemporaryFile |
21 | 23 | from warnings import warn |
22 | 24 |
|
23 | 25 | from six import PY2 |
24 | 26 | from six import binary_type |
25 | 27 | from six import text_type |
| 28 | +from six.moves.urllib.parse import quote |
26 | 29 |
|
27 | 30 | import ZPublisher.HTTPRequest |
28 | 31 | from AccessControl.class_init import InitializeClass |
|
61 | 64 | from cgi import escape |
62 | 65 |
|
63 | 66 |
|
| 67 | +def _get_list_from_env(name, default=None): |
| 68 | + """Get list from environment variable. |
| 69 | +
|
| 70 | + Supports splitting on comma or white space. |
| 71 | + Use the default as fallback only when the variable is not set. |
| 72 | + So if the env variable is set to an empty string, this will ignore the |
| 73 | + default and return an empty list. |
| 74 | + """ |
| 75 | + value = os.environ.get(name) |
| 76 | + if value is None: |
| 77 | + return default or [] |
| 78 | + value = value.strip() |
| 79 | + if "," in value: |
| 80 | + return value.split(",") |
| 81 | + return value.split() |
| 82 | + |
| 83 | + |
| 84 | +# We have one list for allowed, and one for disallowed inline mimetypes. |
| 85 | +# This is for security purposes. |
| 86 | +# By default we use the allowlist. We give integrators the option to choose |
| 87 | +# the denylist via an environment variable. |
| 88 | +ALLOWED_INLINE_MIMETYPES = _get_list_from_env( |
| 89 | + "ALLOWED_INLINE_MIMETYPES", |
| 90 | + default=[ |
| 91 | + "image/gif", |
| 92 | + # The mimetypes registry lists several for jpeg 2000: |
| 93 | + "image/jp2", |
| 94 | + "image/jpeg", |
| 95 | + "image/jpeg2000-image", |
| 96 | + "image/jpeg2000", |
| 97 | + "image/jpx", |
| 98 | + "image/png", |
| 99 | + "image/webp", |
| 100 | + "image/x-icon", |
| 101 | + "image/x-jpeg2000-image", |
| 102 | + "text/plain", |
| 103 | + # By popular request we allow PDF: |
| 104 | + "application/pdf", |
| 105 | + ] |
| 106 | +) |
| 107 | +DISALLOWED_INLINE_MIMETYPES = _get_list_from_env( |
| 108 | + "DISALLOWED_INLINE_MIMETYPES", |
| 109 | + default=[ |
| 110 | + "application/javascript", |
| 111 | + "application/x-javascript", |
| 112 | + "text/javascript", |
| 113 | + "text/html", |
| 114 | + "image/svg+xml", |
| 115 | + "image/svg+xml-compressed", |
| 116 | + ] |
| 117 | +) |
| 118 | +try: |
| 119 | + USE_DENYLIST = os.environ.get("OFS_IMAGE_USE_DENYLIST") |
| 120 | + USE_DENYLIST = bool(int(USE_DENYLIST)) |
| 121 | +except (ValueError, TypeError, AttributeError): |
| 122 | + USE_DENYLIST = False |
| 123 | + |
| 124 | + |
64 | 125 | manage_addFileForm = DTMLFile( |
65 | 126 | 'dtml/imageAdd', |
66 | 127 | globals(), |
@@ -120,6 +181,13 @@ class File( |
120 | 181 | Cacheable |
121 | 182 | ): |
122 | 183 | """A File object is a content object for arbitrary files.""" |
| 184 | + # You can control which mimetypes may be shown inline |
| 185 | + # and which must always be downloaded, for security reasons. |
| 186 | + # Make the configuration available on the class. |
| 187 | + # Then subclasses can override this. |
| 188 | + allowed_inline_mimetypes = ALLOWED_INLINE_MIMETYPES |
| 189 | + disallowed_inline_mimetypes = DISALLOWED_INLINE_MIMETYPES |
| 190 | + use_denylist = USE_DENYLIST |
123 | 191 |
|
124 | 192 | meta_type = 'File' |
125 | 193 | zmi_icon = 'far fa-file-archive' |
@@ -418,6 +486,19 @@ def _range_request_handler(self, REQUEST, RESPONSE): |
418 | 486 | b'\r\n--' + boundary.encode('ascii') + b'--\r\n') |
419 | 487 | return True |
420 | 488 |
|
| 489 | + def _should_force_download(self): |
| 490 | + # If this returns True, the caller should set a |
| 491 | + # Content-Disposition header with filename. |
| 492 | + mimetype = self.content_type |
| 493 | + if not mimetype: |
| 494 | + return False |
| 495 | + if self.use_denylist: |
| 496 | + # We explicitly deny a few mimetypes, and allow the rest. |
| 497 | + return mimetype in self.disallowed_inline_mimetypes |
| 498 | + # Use the allowlist. |
| 499 | + # We only explicitly allow a few mimetypes, and deny the rest. |
| 500 | + return mimetype not in self.allowed_inline_mimetypes |
| 501 | + |
421 | 502 | @security.protected(View) |
422 | 503 | def index_html(self, REQUEST, RESPONSE): |
423 | 504 | """ |
@@ -456,6 +537,24 @@ def index_html(self, REQUEST, RESPONSE): |
456 | 537 | RESPONSE.setHeader('Content-Length', self.size) |
457 | 538 | RESPONSE.setHeader('Accept-Ranges', 'bytes') |
458 | 539 |
|
| 540 | + if self._should_force_download(): |
| 541 | + # We need a filename, even a dummy one if needed. |
| 542 | + filename = self.getId() |
| 543 | + if "." not in filename: |
| 544 | + # This either returns None or ".some_extension" |
| 545 | + ext = guess_extension(self.content_type, strict=False) |
| 546 | + if not ext: |
| 547 | + # image/svg+xml -> svg |
| 548 | + ext = "." + self.content_type.split("/")[-1].split("+")[0] |
| 549 | + filename += ext |
| 550 | + if not isinstance(filename, bytes): |
| 551 | + filename = filename.encode("utf8") |
| 552 | + filename = quote(filename) |
| 553 | + RESPONSE.setHeader( |
| 554 | + "Content-Disposition", |
| 555 | + "attachment; filename*=UTF-8''{}".format(filename), |
| 556 | + ) |
| 557 | + |
459 | 558 | if self.ZCacheable_isCachingEnabled(): |
460 | 559 | result = self.ZCacheable_get(default=None) |
461 | 560 | if result is not None: |
|
0 commit comments