Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: decompression bomb attack in the Filer #1426

Merged
merged 18 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ Limits the maximal file size if set. Takes an integer (file size in MB).

Defaults to ``None``.

``FILER_MAX_IMAGE_PIXELS``
--------------------------------

Limits the maximal pixel size of the image that can be uploaded to the Filer.
It will also be lower than or equals to the MAX_IMAGE_PIXELS that Pillow's PIL allows.


``MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3)``

Defaults to ``MAX_IMAGE_PIXELS``. But when set, should always be lower than the MAX_IMAGE_PIXELS limit set by Pillow.

This is useful setting to prevent decompression bomb DOS attack.


``FILER_ADD_FILE_VALIDATORS``
-----------------------------
Expand Down
64 changes: 36 additions & 28 deletions filer/admin/clipboardadmin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.contrib import admin, messages
from django.core.exceptions import ValidationError
from django.forms.models import modelform_factory
from django.http import JsonResponse
from django.urls import path
Expand All @@ -9,7 +10,7 @@
from ..models import Clipboard, ClipboardItem, Folder
from ..utils.files import handle_request_files_upload, handle_upload
from ..utils.loader import load_model
from ..validation import FileValidationError, validate_upload
from ..validation import validate_upload
from . import views


Expand Down Expand Up @@ -112,48 +113,55 @@
break
uploadform = FileForm({'original_filename': filename, 'owner': request.user.pk},
{'file': upload})
uploadform.request = request
uploadform.instance.mime_type = mime_type
if uploadform.is_valid():
try:
validate_upload(filename, upload, request.user, mime_type)
except FileValidationError as error:
from django.contrib.messages import ERROR, add_message
message = str(error)
add_message(request, ERROR, message)
return JsonResponse({'error': message})
file_obj = uploadform.save(commit=False)
# Enforce the FILER_IS_PUBLIC_DEFAULT
file_obj.is_public = filer_settings.FILER_IS_PUBLIC_DEFAULT
file_obj = uploadform.save(commit=False)
# Enforce the FILER_IS_PUBLIC_DEFAULT
file_obj.is_public = filer_settings.FILER_IS_PUBLIC_DEFAULT
except ValidationError as error:
messages.error(request, str(error))
return JsonResponse({'error': str(error)})
Dismissed Show dismissed Hide dismissed
file_obj.folder = folder
file_obj.save()
# TODO: Deprecated/refactor
# clipboard_item = ClipboardItem(
# clipboard=clipboard, file=file_obj)
# clipboard_item.save()

thumbnail = None
data = {
'thumbnail': thumbnail,
'alt_text': '',
'label': str(file_obj),
'file_id': file_obj.pk,
}
# prepare preview thumbnail
if isinstance(file_obj, Image):
thumbnail_180_options = {
'size': (180, 180),
'crop': True,
'upscale': True,
try:
thumbnail = None
data = {
'thumbnail': thumbnail,
'alt_text': '',
'label': str(file_obj),
'file_id': file_obj.pk,
}
thumbnail_180 = file_obj.file.get_thumbnail(
thumbnail_180_options)
data['thumbnail_180'] = thumbnail_180.url
data['original_image'] = file_obj.url
return JsonResponse(data)
# prepare preview thumbnail
if isinstance(file_obj, Image):
thumbnail_180_options = {
'size': (180, 180),
'crop': True,
'upscale': True,
}
thumbnail_180 = file_obj.file.get_thumbnail(
thumbnail_180_options)
data['thumbnail_180'] = thumbnail_180.url
data['original_image'] = file_obj.url
return JsonResponse(data)
except Exception as error:
messages.error(request, str(error))
return JsonResponse({"error": str(error)})

Check warning on line 156 in filer/admin/clipboardadmin.py

View check run for this annotation

Codecov / codecov/patch

filer/admin/clipboardadmin.py#L154-L156

Added lines #L154 - L156 were not covered by tests

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
else:
for key, error_list in uploadform.errors.items():
for error in error_list:
messages.error(request, error)

form_errors = '; '.join(['{}: {}'.format(
field,
', '.join(errors)) for field, errors in list(
uploadform.errors.items())
])
return JsonResponse({'message': str(form_errors)}, status=422)
return JsonResponse({'error': str(form_errors)}, status=200)
4 changes: 2 additions & 2 deletions filer/fields/multistorage_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class MultiStorageFileDescriptor(FileDescriptor):
"""
This is rather similar to Django's ImageFileDescriptor.
It calls <field name>_data_changed on model instance when new
value is set. The callback is suposed to update fields which
value is set. The callback is supposed to update fields which
are related to file data (like size, checksum, etc.).
When this is called from model __init__ (prev_assigned=False),
it does nothing because related fields might not have values yet.
Expand All @@ -58,7 +58,7 @@ def __set__(self, instance, value):
# To prevent recalculating file data related attributes when we are instantiating
# an object from the database, update only if the field had a value before this assignment.
# To prevent recalculating upon reassignment of the same file, update only if value is
# different than the previous one.
# different from the previous one.
if prev_assigned and value != previous_file:
callback_attr = f'{self.field.name}_data_changed'
if hasattr(instance, callback_attr):
Expand Down
46 changes: 46 additions & 0 deletions filer/models/abstract.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import logging

Check failure on line 1 in filer/models/abstract.py

View workflow job for this annotation

GitHub Actions / isort

Imports are incorrectly sorted and/or formatted.

from PIL.Image import MAX_IMAGE_PIXELS
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
Expand All @@ -17,6 +20,16 @@
logger = logging.getLogger(__name__)


# We can never exceed the max pixel value set by Pillow's PIL Image MAX_IMAGE_PIXELS
# as if we allow it, it will fail while thumbnailing (first in the admin thumbnails
# and then in the page itself.
# Refer this https://github.com/python-pillow/Pillow/blob/b723e9e62e4706a85f7e44cb42b3d838dae5e546/src/PIL/Image.py#L3148
FILER_MAX_IMAGE_PIXELS = min(
getattr(settings, "FILER_MAX_IMAGE_PIXELS", MAX_IMAGE_PIXELS),
MAX_IMAGE_PIXELS,
)


class BaseImage(File):
SIDEBAR_IMAGE_WIDTH = 210
DEFAULT_THUMBNAILS = {
Expand Down Expand Up @@ -112,6 +125,39 @@
self._transparent = False
return attrs_updated

def clean(self):
# We check the Image size and calculate the pixel before
# the image gets attached to a folder and saved. We also
# send the error msg in the JSON and also post the message
# so that they know what is wrong with the image they uploaded

if self._width is None or self._height is None:
pixels = 2 * FILER_MAX_IMAGE_PIXELS + 1
aspect = 16 / 9

Check warning on line 136 in filer/models/abstract.py

View check run for this annotation

Codecov / codecov/patch

filer/models/abstract.py#L135-L136

Added lines #L135 - L136 were not covered by tests
else:
width, height = max(1, self.width), max(1, self.height)
pixels: int = width * height
aspect: float = width / height
res_x: int = int((FILER_MAX_IMAGE_PIXELS * aspect) ** 0.5)
res_y: int = int(res_x / aspect)
if pixels > 2 * FILER_MAX_IMAGE_PIXELS:
msg = _(
"Image format not recognized or image size exceeds limit of %(max_pixels)d million "
"pixels by a factor of two or more. Check file format or resize image to "
"%(width)d x %(height)d) resolution or lower."
) % dict(max_pixels=FILER_MAX_IMAGE_PIXELS // 1000000, width=res_x, height=res_y)
raise ValidationError(str(msg), code="image_size")

if pixels > FILER_MAX_IMAGE_PIXELS:
# Can I catch warnings?
msg = _(
"Image size (%(pixels)d million pixels) exceeds limit of %(max_pixels)d "
"million pixels. Resize image to %(width)d x %(height)d resolution "
"or lower."
) % dict(pixels=pixels // 1000000, max_pixels=FILER_MAX_IMAGE_PIXELS // 1000000,
width=res_x, height=res_y)
raise ValidationError(str(msg), code="image_size")

def save(self, *args, **kwargs):
self.has_all_mandatory_data = self._check_validity()
super().save(*args, **kwargs)
Expand Down
3 changes: 2 additions & 1 deletion filer/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

from django.apps import apps
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _


User = get_user_model() # Needed for typing


class FileValidationError(Exception):
class FileValidationError(ValidationError):
pass


Expand Down
48 changes: 48 additions & 0 deletions tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os

import django
Expand All @@ -7,6 +8,7 @@
from django.contrib.admin import helpers
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.messages import get_messages, ERROR
from django.forms.models import model_to_dict as model_to_dict_django
from django.http import HttpRequest, HttpResponseForbidden
from django.test import RequestFactory, TestCase
Expand All @@ -18,6 +20,7 @@
from filer import settings as filer_settings
from filer.admin import tools
from filer.admin.folderadmin import FolderAdmin
from filer.models import abstract
from filer.models.filemodels import File
from filer.models.foldermodels import Folder, FolderPermission
from filer.models.virtualitems import FolderRoot
Expand Down Expand Up @@ -475,6 +478,51 @@ def test_filer_ajax_upload_file(self):
self.assertEqual(stored_image.original_filename, self.image_name)
self.assertEqual(stored_image.mime_type, 'image/jpeg')

def test_filer_ajax_decompression_bomb(self):
DEFAULT_MAX_IMAGE_PIXELS = abstract.FILER_MAX_IMAGE_PIXELS
abstract.FILER_MAX_IMAGE_PIXELS = 800 * 200 # Triggers error
self.assertEqual(Image.objects.count(), 0)
folder = Folder.objects.create(name='foo')
with open(self.filename, 'rb') as fh:
file_obj = django.core.files.File(fh)
url = reverse(
'admin:filer-ajax_upload',
kwargs={'folder_id': folder.pk}
) + '?filename=%s' % self.image_name
response = self.client.post(
url,
data=file_obj.read(),
content_type='image/jpeg',
**{'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
)
self.assertEqual(Image.objects.count(), 0)
self.assertIn("error", json.loads(response.content.decode("utf-8")))
messages = list(get_messages(response.wsgi_request))
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0].level, ERROR)

abstract.FILER_MAX_IMAGE_PIXELS = 800 * 300 # Triggers warning
folder = Folder.objects.create(name='foo')
with open(self.filename, 'rb') as fh:
file_obj = django.core.files.File(fh)
url = reverse(
'admin:filer-ajax_upload',
kwargs={'folder_id': folder.pk}
) + '?filename=%s' % self.image_name
response = self.client.post(
url,
data=file_obj.read(),
content_type='image/jpeg',
**{'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
)
self.assertEqual(response.status_code, 200)
messages = list(get_messages(response.wsgi_request))
self.assertEqual(len(messages), 2) # One more message
self.assertEqual(messages[1].level, ERROR)
self.assertEqual(Image.objects.count(), 0)

abstract.FILER_MAX_IMAGE_PIXELS = DEFAULT_MAX_IMAGE_PIXELS

def test_filer_ajax_upload_file_using_content_type(self):
self.assertEqual(Image.objects.count(), 0)
folder = Folder.objects.create(name='foo')
Expand Down
1 change: 1 addition & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def test_upload_image_form(self):
'original_filename': self.image_name,
'owner': self.superuser.pk
}, {'file': file_obj})
self.assertTrue(upoad_image_form.is_valid())
if upoad_image_form.is_valid():
image = upoad_image_form.save() # noqa
self.assertEqual(Image.objects.count(), 1)
Expand Down
Loading