Skip to content

Commit 964f48d

Browse files
authored
fix: decompression bomb attack in the Filer (#1426)
1 parent 8293ba1 commit 964f48d

File tree

78 files changed

+2943
-1849
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+2943
-1849
lines changed

CHANGELOG.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,18 @@ CHANGELOG
55
unreleased
66
==========
77

8+
* feat: limit uploaded image area (width x height) to prevent decompression
9+
bombs
10+
* fix: Run validators on updated files in file change view
11+
* fix: Update mime type if uploading file in file change view
12+
* fix: Do not allow to remove the file field from an uplaoded file in
13+
the admin interface
14+
* fix: refactor upload checks into running validators in the admin
15+
and adding clean methods for file and (abstract) image models.
816
* Fixed two more instances of javascript int overflow issue (#1335)
917
* fix: ensure uniqueness of icon admin url names
18+
* fix: Crash with django-storage if filer file does not have a
19+
storage file attached
1020

1121
3.0.6 (2023-09-08)
1222
==================

docs/settings.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ Limits the maximal file size if set. Takes an integer (file size in MB).
178178

179179
Defaults to ``None``.
180180

181+
``FILER_MAX_IMAGE_PIXELS``
182+
--------------------------------
183+
184+
Limits the maximal pixel size of the image that can be uploaded to the Filer.
185+
It will also be lower than or equals to the MAX_IMAGE_PIXELS that Pillow's PIL allows.
186+
187+
188+
``MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3)``
189+
190+
Defaults to ``MAX_IMAGE_PIXELS``. But when set, should always be lower than the MAX_IMAGE_PIXELS limit set by Pillow.
191+
192+
This is useful setting to prevent decompression bomb DOS attack.
193+
181194

182195
``FILER_ADD_FILE_VALIDATORS``
183196
-----------------------------

filer/admin/clipboardadmin.py

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.contrib import admin, messages
2+
from django.core.exceptions import ValidationError
23
from django.forms.models import modelform_factory
34
from django.http import JsonResponse
45
from django.urls import path
@@ -9,7 +10,7 @@
910
from ..models import Clipboard, ClipboardItem, Folder
1011
from ..utils.files import handle_request_files_upload, handle_upload
1112
from ..utils.loader import load_model
12-
from ..validation import FileValidationError, validate_upload
13+
from ..validation import validate_upload
1314
from . import views
1415

1516

@@ -112,48 +113,53 @@ def ajax_upload(request, folder_id=None):
112113
break
113114
uploadform = FileForm({'original_filename': filename, 'owner': request.user.pk},
114115
{'file': upload})
116+
uploadform.request = request
115117
uploadform.instance.mime_type = mime_type
116118
if uploadform.is_valid():
117119
try:
118120
validate_upload(filename, upload, request.user, mime_type)
119-
except FileValidationError as error:
120-
from django.contrib.messages import ERROR, add_message
121-
message = str(error)
122-
add_message(request, ERROR, message)
123-
return JsonResponse({'error': message})
124-
file_obj = uploadform.save(commit=False)
125-
# Enforce the FILER_IS_PUBLIC_DEFAULT
126-
file_obj.is_public = filer_settings.FILER_IS_PUBLIC_DEFAULT
121+
file_obj = uploadform.save(commit=False)
122+
# Enforce the FILER_IS_PUBLIC_DEFAULT
123+
file_obj.is_public = filer_settings.FILER_IS_PUBLIC_DEFAULT
124+
except ValidationError as error:
125+
messages.error(request, str(error))
126+
return JsonResponse({'error': str(error)})
127127
file_obj.folder = folder
128128
file_obj.save()
129129
# TODO: Deprecated/refactor
130130
# clipboard_item = ClipboardItem(
131131
# clipboard=clipboard, file=file_obj)
132132
# clipboard_item.save()
133133

134-
thumbnail = None
135-
data = {
136-
'thumbnail': thumbnail,
137-
'alt_text': '',
138-
'label': str(file_obj),
139-
'file_id': file_obj.pk,
140-
}
141-
# prepare preview thumbnail
142-
if isinstance(file_obj, Image):
143-
thumbnail_180_options = {
144-
'size': (180, 180),
145-
'crop': True,
146-
'upscale': True,
134+
try:
135+
thumbnail = None
136+
data = {
137+
'thumbnail': thumbnail,
138+
'alt_text': '',
139+
'label': str(file_obj),
140+
'file_id': file_obj.pk,
147141
}
148-
thumbnail_180 = file_obj.file.get_thumbnail(
149-
thumbnail_180_options)
150-
data['thumbnail_180'] = thumbnail_180.url
151-
data['original_image'] = file_obj.url
152-
return JsonResponse(data)
142+
# prepare preview thumbnail
143+
if isinstance(file_obj, Image):
144+
thumbnail_180_options = {
145+
'size': (180, 180),
146+
'crop': True,
147+
'upscale': True,
148+
}
149+
thumbnail_180 = file_obj.file.get_thumbnail(
150+
thumbnail_180_options)
151+
data['thumbnail_180'] = thumbnail_180.url
152+
data['original_image'] = file_obj.url
153+
return JsonResponse(data)
154+
except Exception as error:
155+
messages.error(request, str(error))
156+
return JsonResponse({"error": str(error)})
153157
else:
154-
form_errors = '; '.join(['{}: {}'.format(
155-
field,
156-
', '.join(errors)) for field, errors in list(
157-
uploadform.errors.items())
158+
for key, error_list in uploadform.errors.items():
159+
for error in error_list:
160+
messages.error(request, error)
161+
162+
form_errors = '; '.join(['{}'.format(
163+
', '.join(errors)) for errors in list(uploadform.errors.values())
158164
])
159-
return JsonResponse({'message': str(form_errors)}, status=422)
165+
return JsonResponse({'error': str(form_errors)}, status=200)

filer/admin/fileadmin.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import mimetypes
2+
13
from django import forms
24
from django.contrib.admin.utils import unquote
35
from django.contrib.staticfiles.storage import staticfiles_storage
@@ -8,6 +10,7 @@
810
from django.utils.timezone import now
911
from django.utils.translation import gettext as _
1012

13+
from easy_thumbnails.engine import NoSourceGenerator
1114
from easy_thumbnails.exceptions import InvalidImageFormatError
1215
from easy_thumbnails.files import get_thumbnailer
1316
from easy_thumbnails.models import Thumbnail as EasyThumbnail
@@ -25,6 +28,27 @@ class Meta:
2528
model = File
2629
exclude = ()
2730

31+
def __init__(self, *args, **kwargs):
32+
super().__init__(*args, **kwargs)
33+
self.fields["file"].widget = forms.FileInput()
34+
35+
def clean(self):
36+
from ..validation import validate_upload
37+
cleaned_data = super().clean()
38+
if "file" in self.changed_data and cleaned_data["file"]:
39+
mime_type = mimetypes.guess_type(cleaned_data["file"].name)[0] or 'application/octet-stream'
40+
file = cleaned_data["file"]
41+
file.open("w+") # Allow for sanitizing upload
42+
file.seek(0)
43+
validate_upload(
44+
file_name=cleaned_data["file"].name,
45+
file=file.file,
46+
owner=cleaned_data["owner"],
47+
mime_type=mime_type,
48+
)
49+
file.open("r")
50+
return self.cleaned_data
51+
2852

2953
class FileAdmin(PrimitivePermissionAwareModelAdmin):
3054
list_display = ('label',)
@@ -185,7 +209,7 @@ def icon_view(self, request, file_id: int, size: int) -> HttpResponse:
185209
# Touch thumbnail to allow it to be prefetched for directory listing
186210
EasyThumbnail.objects.filter(name=thumbnail.name).update(modified=now())
187211
return HttpResponseRedirect(thumbnail.url)
188-
except InvalidImageFormatError:
212+
except (InvalidImageFormatError, NoSourceGenerator):
189213
return HttpResponseRedirect(staticfiles_storage.url('filer/icons/file-missing.svg'))
190214

191215

filer/admin/imageadmin.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
from ..thumbnail_processors import normalize_subject_location
77
from ..utils.compatibility import string_concat
88
from ..utils.loader import load_model
9-
from .fileadmin import FileAdmin
9+
from .fileadmin import FileAdmin, FileAdminChangeFrom
1010

1111

1212
Image = load_model(FILER_IMAGE_MODEL)
1313

1414

15-
class ImageAdminForm(forms.ModelForm):
15+
class ImageAdminForm(FileAdminChangeFrom):
1616
subject_location = forms.CharField(
1717
max_length=64, required=False,
1818
label=_('Subject location'),
@@ -58,8 +58,8 @@ def clean_subject_location(self):
5858
err_code = 'invalid_subject_format'
5959

6060
elif (
61-
coordinates[0] > self.instance.width
62-
or coordinates[1] > self.instance.height
61+
coordinates[0] > self.instance.width > 0
62+
or coordinates[1] > self.instance.height > 0
6363
):
6464
err_msg = gettext_lazy(
6565
'Subject location is outside of the image. ')

filer/fields/multistorage_file.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class MultiStorageFileDescriptor(FileDescriptor):
4343
"""
4444
This is rather similar to Django's ImageFileDescriptor.
4545
It calls <field name>_data_changed on model instance when new
46-
value is set. The callback is suposed to update fields which
46+
value is set. The callback is supposed to update fields which
4747
are related to file data (like size, checksum, etc.).
4848
When this is called from model __init__ (prev_assigned=False),
4949
it does nothing because related fields might not have values yet.
@@ -58,7 +58,7 @@ def __set__(self, instance, value):
5858
# To prevent recalculating file data related attributes when we are instantiating
5959
# an object from the database, update only if the field had a value before this assignment.
6060
# To prevent recalculating upon reassignment of the same file, update only if value is
61-
# different than the previous one.
61+
# different from the previous one.
6262
if prev_assigned and value != previous_file:
6363
callback_attr = f'{self.field.name}_data_changed'
6464
if hasattr(instance, callback_attr):
@@ -123,7 +123,7 @@ def exists(self):
123123
"""
124124
Returns ``True`` if underlying file exists in storage.
125125
"""
126-
return self.storage.exists(self.name)
126+
return self.name and self.storage.exists(self.name)
127127

128128

129129
class MultiStorageFileField(easy_thumbnails_fields.ThumbnailerField):
0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)