|
14 | 14 | """ |
15 | 15 |
|
16 | 16 | import html |
| 17 | +import os |
17 | 18 | import struct |
18 | 19 | from email.generator import _make_boundary |
19 | 20 | from io import BytesIO |
| 21 | +from mimetypes import guess_extension |
| 22 | +from urllib.parse import quote |
20 | 23 | from xml.dom import minidom |
21 | 24 |
|
22 | 25 | import ZPublisher.HTTPRequest |
|
48 | 51 | from ZPublisher.HTTPRequest import FileUpload |
49 | 52 |
|
50 | 53 |
|
| 54 | +def _get_list_from_env(name, default=None): |
| 55 | + """Get list from environment variable. |
| 56 | +
|
| 57 | + Supports splitting on comma or white space. |
| 58 | + Use the default as fallback only when the variable is not set. |
| 59 | + So if the env variable is set to an empty string, this will ignore the |
| 60 | + default and return an empty list. |
| 61 | + """ |
| 62 | + value = os.environ.get(name) |
| 63 | + if value is None: |
| 64 | + return default or [] |
| 65 | + value = value.strip() |
| 66 | + if "," in value: |
| 67 | + return value.split(",") |
| 68 | + return value.split() |
| 69 | + |
| 70 | + |
| 71 | +# We have one list for allowed, and one for disallowed inline mimetypes. |
| 72 | +# This is for security purposes. |
| 73 | +# By default we use the allowlist. We give integrators the option to choose |
| 74 | +# the denylist via an environment variable. |
| 75 | +ALLOWED_INLINE_MIMETYPES = _get_list_from_env( |
| 76 | + "ALLOWED_INLINE_MIMETYPES", |
| 77 | + default=[ |
| 78 | + "image/gif", |
| 79 | + # The mimetypes registry lists several for jpeg 2000: |
| 80 | + "image/jp2", |
| 81 | + "image/jpeg", |
| 82 | + "image/jpeg2000-image", |
| 83 | + "image/jpeg2000", |
| 84 | + "image/jpx", |
| 85 | + "image/png", |
| 86 | + "image/webp", |
| 87 | + "image/x-icon", |
| 88 | + "image/x-jpeg2000-image", |
| 89 | + "text/plain", |
| 90 | + # By popular request we allow PDF: |
| 91 | + "application/pdf", |
| 92 | + ] |
| 93 | +) |
| 94 | +DISALLOWED_INLINE_MIMETYPES = _get_list_from_env( |
| 95 | + "DISALLOWED_INLINE_MIMETYPES", |
| 96 | + default=[ |
| 97 | + "application/javascript", |
| 98 | + "application/x-javascript", |
| 99 | + "text/javascript", |
| 100 | + "text/html", |
| 101 | + "image/svg+xml", |
| 102 | + "image/svg+xml-compressed", |
| 103 | + ] |
| 104 | +) |
| 105 | +try: |
| 106 | + USE_DENYLIST = os.environ.get("OFS_IMAGE_USE_DENYLIST") |
| 107 | + USE_DENYLIST = bool(int(USE_DENYLIST)) |
| 108 | +except (ValueError, TypeError, AttributeError): |
| 109 | + USE_DENYLIST = False |
| 110 | + |
| 111 | + |
51 | 112 | manage_addFileForm = DTMLFile( |
52 | 113 | 'dtml/imageAdd', |
53 | 114 | globals(), |
@@ -107,6 +168,13 @@ class File( |
107 | 168 | Cacheable |
108 | 169 | ): |
109 | 170 | """A File object is a content object for arbitrary files.""" |
| 171 | + # You can control which mimetypes may be shown inline |
| 172 | + # and which must always be downloaded, for security reasons. |
| 173 | + # Make the configuration available on the class. |
| 174 | + # Then subclasses can override this. |
| 175 | + allowed_inline_mimetypes = ALLOWED_INLINE_MIMETYPES |
| 176 | + disallowed_inline_mimetypes = DISALLOWED_INLINE_MIMETYPES |
| 177 | + use_denylist = USE_DENYLIST |
110 | 178 |
|
111 | 179 | meta_type = 'File' |
112 | 180 | zmi_icon = 'far fa-file-archive' |
@@ -403,6 +471,19 @@ def _range_request_handler(self, REQUEST, RESPONSE): |
403 | 471 | b'\r\n--' + boundary.encode('ascii') + b'--\r\n') |
404 | 472 | return True |
405 | 473 |
|
| 474 | + def _should_force_download(self): |
| 475 | + # If this returns True, the caller should set a |
| 476 | + # Content-Disposition header with filename. |
| 477 | + mimetype = self.content_type |
| 478 | + if not mimetype: |
| 479 | + return False |
| 480 | + if self.use_denylist: |
| 481 | + # We explicitly deny a few mimetypes, and allow the rest. |
| 482 | + return mimetype in self.disallowed_inline_mimetypes |
| 483 | + # Use the allowlist. |
| 484 | + # We only explicitly allow a few mimetypes, and deny the rest. |
| 485 | + return mimetype not in self.allowed_inline_mimetypes |
| 486 | + |
406 | 487 | @security.protected(View) |
407 | 488 | def index_html(self, REQUEST, RESPONSE): |
408 | 489 | """ |
@@ -441,6 +522,22 @@ def index_html(self, REQUEST, RESPONSE): |
441 | 522 | RESPONSE.setHeader('Content-Length', self.size) |
442 | 523 | RESPONSE.setHeader('Accept-Ranges', 'bytes') |
443 | 524 |
|
| 525 | + if self._should_force_download(): |
| 526 | + # We need a filename, even a dummy one if needed. |
| 527 | + filename = self.getId() |
| 528 | + if "." not in filename: |
| 529 | + # This either returns None or ".some_extension" |
| 530 | + ext = guess_extension(self.content_type, strict=False) |
| 531 | + if not ext: |
| 532 | + # image/svg+xml -> svg |
| 533 | + ext = "." + self.content_type.split("/")[-1].split("+")[0] |
| 534 | + filename += f"{ext}" |
| 535 | + filename = quote(filename.encode("utf8")) |
| 536 | + RESPONSE.setHeader( |
| 537 | + "Content-Disposition", |
| 538 | + f"attachment; filename*=UTF-8''{filename}", |
| 539 | + ) |
| 540 | + |
444 | 541 | if self.ZCacheable_isCachingEnabled(): |
445 | 542 | result = self.ZCacheable_get(default=None) |
446 | 543 | if result is not None: |
|
0 commit comments