Skip to content

Commit 50ce8a0

Browse files
authored
add protection for image uploads (#7295)
* add more protection for image uploads * gracefully handle file-size errors in GroupProfileAdmin * create validate_avatar_size utility function
1 parent cc798e3 commit 50ce8a0

File tree

12 files changed

+278
-152
lines changed

12 files changed

+278
-152
lines changed

kitsune/gallery/tests/test_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from kitsune.upload.tasks import generate_thumbnail
99

1010

11-
@patch("kitsune.upload.tasks.create_image_thumbnail")
11+
@patch("kitsune.upload.utils.create_image_thumbnail")
1212
class ImageTestCase(TestCase):
1313
def tearDown(self):
1414
Image.objects.all().delete()

kitsune/groups/admin.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@
55
from treebeard.forms import movenodeform_factory
66

77
from kitsune.groups.models import GroupProfile
8-
from kitsune.upload.tasks import create_image_thumbnail
8+
from kitsune.upload.utils import create_image_thumbnail, validate_avatar_size
9+
10+
11+
class GroupProfileAdminForm(movenodeform_factory(GroupProfile)): # type: ignore[misc]
12+
def clean_avatar(self):
13+
avatar = self.cleaned_data.get("avatar")
14+
if avatar and hasattr(avatar, "size"):
15+
validate_avatar_size(avatar)
16+
return avatar
917

1018

1119
class GroupProfileAdmin(TreeAdmin):
12-
form = movenodeform_factory(GroupProfile)
20+
form = GroupProfileAdminForm
1321
list_display = ["slug", "group", "visibility", "depth", "numchild"]
1422
list_filter = ["visibility"]
1523
raw_id_fields = ["leaders"]

kitsune/groups/forms.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from kitsune.sumo.form_fields import MultiUsernameField
88
from kitsune.sumo.widgets import ImageWidget
99
from kitsune.upload.forms import LimitedImageField
10-
from kitsune.upload.utils import FileTooLargeError, check_file_size
10+
from kitsune.upload.utils import validate_avatar_size
1111

1212

1313
class GroupProfileForm(forms.ModelForm):
@@ -41,11 +41,7 @@ def clean_avatar(self):
4141
avatar = self.request.FILES.get("avatar")
4242
if not avatar:
4343
raise forms.ValidationError(_("You have not selected an image to upload."))
44-
# Validate file size
45-
try:
46-
check_file_size(avatar, settings.MAX_AVATAR_FILE_SIZE)
47-
except FileTooLargeError as e:
48-
raise forms.ValidationError(e.args[0])
44+
validate_avatar_size(avatar)
4945

5046
return self.cleaned_data["avatar"]
5147

kitsune/groups/tests/test_admin.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
from unittest import mock
2+
13
from django import forms
24
from django.contrib.admin.sites import AdminSite
35
from django.contrib.auth.models import Group
6+
from django.core.files.uploadedfile import SimpleUploadedFile
47
from django.test import RequestFactory
58

6-
from kitsune.groups.admin import GroupProfileAdmin
9+
from kitsune.groups.admin import GroupProfileAdmin, GroupProfileAdminForm
710
from kitsune.groups.models import GroupProfile
811
from kitsune.sumo.tests import TestCase
12+
from kitsune.upload.utils import FileTooLargeError
913
from kitsune.users.tests import UserFactory
1014

1115

@@ -94,3 +98,41 @@ def test_subgroup_can_have_no_leaders(self):
9498
self.admin.save_related(mock_request, mock_form, [], change=True)
9599

96100
self.assertEqual(sub_profile.leaders.count(), 0)
101+
102+
@mock.patch("kitsune.upload.utils.open_as_pil_image")
103+
def test_avatar_too_large_pixels(self, mock_open):
104+
"""Uploading an avatar that exceeds pixel limits shows a form validation error."""
105+
mock_open.side_effect = FileTooLargeError("Image exceeds the maximum allowed size.")
106+
107+
with open("kitsune/upload/tests/media/test.jpg", "rb") as f:
108+
avatar = SimpleUploadedFile("test.jpg", f.read(), content_type="image/jpeg")
109+
group = Group.objects.create(name="Avatar Test Group")
110+
profile = GroupProfile.add_root(group=group, slug="avatar-test-group")
111+
112+
form = GroupProfileAdminForm(
113+
data={"group": group.pk, "slug": "avatar-test-group"},
114+
files={"avatar": avatar},
115+
instance=profile,
116+
)
117+
self.assertFalse(form.is_valid())
118+
self.assertIn("avatar", form.errors)
119+
self.assertIn("Image exceeds the maximum allowed size.", form.errors["avatar"][0])
120+
121+
@mock.patch("kitsune.upload.utils.check_file_size")
122+
def test_avatar_too_large_file_size(self, mock_check):
123+
"""Uploading an avatar that exceeds file size limits shows a form validation error."""
124+
mock_check.side_effect = FileTooLargeError("File is too large.")
125+
126+
with open("kitsune/upload/tests/media/test.jpg", "rb") as f:
127+
avatar = SimpleUploadedFile("test.jpg", f.read(), content_type="image/jpeg")
128+
group = Group.objects.create(name="Size Test Group")
129+
profile = GroupProfile.add_root(group=group, slug="size-test-group")
130+
131+
form = GroupProfileAdminForm(
132+
data={"group": group.pk, "slug": "size-test-group"},
133+
files={"avatar": avatar},
134+
instance=profile,
135+
)
136+
self.assertFalse(form.is_valid())
137+
self.assertIn("avatar", form.errors)
138+
self.assertIn("File is too large.", form.errors["avatar"][0])

kitsune/groups/tests/test_views.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.contrib.auth.models import Group
66
from django.core.files import File
77
from django.http import HttpResponse
8-
from django.test import RequestFactory
8+
from django.test import RequestFactory, override_settings
99
from pyquery import PyQuery as pq
1010

1111
from kitsune.groups.models import GroupProfile
@@ -127,6 +127,14 @@ def test_upload_avatar(self):
127127
self.assertEqual(url, r["location"])
128128
assert not os.path.exists(old_path), "Old avatar was not removed."
129129

130+
@override_settings(IMAGE_MAX_PIXELS=1)
131+
def test_upload_avatar_too_large(self):
132+
"""Uploading an avatar that exceeds IMAGE_MAX_PIXELS fails form validation."""
133+
url = reverse("groups.edit_avatar", locale="en-US", args=[self.group_profile.slug])
134+
with open("kitsune/upload/tests/media/test.jpg", "rb") as f:
135+
r = self.client.post(url, {"avatar": f})
136+
self.assertEqual(200, r.status_code)
137+
130138
def test_delete_avatar(self):
131139
"""Delete a group avatar."""
132140
self.test_upload_avatar()

kitsune/groups/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from kitsune.groups.models import GroupProfile
1616
from kitsune.sumo.urlresolvers import reverse
1717
from kitsune.sumo.utils import get_next_url, paginate
18-
from kitsune.upload.tasks import create_image_thumbnail
18+
from kitsune.upload.utils import create_image_thumbnail
1919

2020

2121
def _remove_group_member(profile, user, request, remove_from_group=True):

kitsune/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,7 @@ def JINJA_CONFIG():
840840
WIKI_VIDEO_HEIGHT = 480
841841

842842
IMAGE_MAX_FILESIZE = 10485760 # 10 megabytes, in bytes
843+
IMAGE_MAX_PIXELS = 20_000_000 # 20 megapixels
843844
THUMBNAIL_SIZE = 120 # Thumbnail size, in pixels
844845
THUMBNAIL_UPLOAD_PATH = "uploads/images/thumbnails/"
845846
IMAGE_UPLOAD_PATH = "uploads/images/"

kitsune/upload/tasks.py

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import io
21
import logging
32
import os
43
import subprocess
@@ -9,7 +8,6 @@
98
from django.conf import settings
109
from django.core.files.base import ContentFile
1110
from django.core.files.storage import default_storage
12-
from PIL import Image
1311

1412
log = logging.getLogger("k.task")
1513

@@ -23,6 +21,7 @@ def generate_thumbnail(
2321
optionally the "max_size" of its longest side. The model name must be in the form of
2422
"<app>.<model-name>", so for example, "gallery.Image" or "upload.ImageAttachment".
2523
"""
24+
from kitsune.upload.utils import create_image_thumbnail
2625

2726
# Get the model of the object, and then get the object itself.
2827
model = apps.get_model(obj_model_name)
@@ -51,37 +50,6 @@ def generate_thumbnail(
5150
obj.update(**{to_field: to_.name})
5251

5352

54-
def create_image_thumbnail(fileobj, longest_side=settings.THUMBNAIL_SIZE, pad=False):
55-
"""
56-
Returns a thumbnail file with a set longest side.
57-
"""
58-
original_image = Image.open(fileobj)
59-
original_image = original_image.convert("RGBA")
60-
file_width, file_height = original_image.size
61-
62-
width, height = _scale_dimensions(file_width, file_height, longest_side)
63-
resized_image = original_image.resize((width, height), Image.LANCZOS)
64-
65-
data = io.BytesIO()
66-
67-
if pad:
68-
padded_image = _make_image_square(resized_image, longest_side)
69-
padded_image.save(data, "PNG")
70-
else:
71-
resized_image.save(data, "PNG")
72-
73-
return ContentFile(data.getvalue())
74-
75-
76-
def _make_image_square(source_image, side=settings.THUMBNAIL_SIZE):
77-
"""Pads a rectangular image with transparency to make it square."""
78-
square_image = Image.new("RGBA", (side, side), (255, 255, 255, 0))
79-
width = (side - source_image.size[0]) // 2
80-
height = (side - source_image.size[1]) // 2
81-
square_image.paste(source_image, (width, height))
82-
return square_image
83-
84-
8553
def _scale_dimensions(width, height, longest_side=settings.THUMBNAIL_SIZE):
8654
"""
8755
Returns a tuple (width, height), both smaller than longest side, and

kitsune/upload/tests/__init__.py

Lines changed: 0 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import factory
2-
from django.conf import settings
3-
from django.core.files import File
42

53
from kitsune.questions.tests import QuestionFactory
64
from kitsune.sumo.tests import TestCase
75
from kitsune.upload.models import ImageAttachment
86
from kitsune.upload.storage import RenameFileStorage
9-
from kitsune.upload.utils import FileTooLargeError, check_file_size, create_imageattachment
107
from kitsune.users.tests import UserFactory
118

129

@@ -32,103 +29,3 @@ def check_file_info(file_info, name, width, height, delete_url, url, thumbnail_u
3229
def get_file_name(name):
3330
storage = RenameFileStorage()
3431
return storage.get_available_name(name)
35-
36-
37-
class CheckFileSizeTestCase(TestCase):
38-
"""Tests for check_file_size"""
39-
40-
def test_check_file_size_under(self):
41-
"""No exception should be raised"""
42-
with open("kitsune/upload/tests/media/test.jpg", "rb") as f:
43-
up_file = File(f)
44-
check_file_size(up_file, settings.IMAGE_MAX_FILESIZE)
45-
46-
def test_check_file_size_over(self):
47-
"""FileTooLargeError should be raised"""
48-
with self.assertRaises(FileTooLargeError):
49-
with open("kitsune/upload/tests/media/test.jpg", "rb") as f:
50-
up_file = File(f)
51-
# This should raise
52-
check_file_size(up_file, 0)
53-
54-
55-
class CreateImageAttachmentTestCase(TestCase):
56-
def setUp(self):
57-
super().setUp()
58-
self.user = UserFactory()
59-
self.obj = QuestionFactory()
60-
61-
def tearDown(self):
62-
ImageAttachment.objects.all().delete()
63-
super().tearDown()
64-
65-
def test_create_imageattachment(self):
66-
"""
67-
An image attachment is created from an uploaded file.
68-
69-
Verifies all appropriate fields are correctly set.
70-
"""
71-
with open("kitsune/upload/tests/media/test.jpg", "rb") as f:
72-
up_file = File(f)
73-
file_info = create_imageattachment({"image": up_file}, self.user, self.obj)
74-
75-
image = ImageAttachment.objects.all()[0]
76-
check_file_info(
77-
file_info,
78-
name="test.png",
79-
width=90,
80-
height=120,
81-
delete_url=image.get_delete_url(),
82-
url=image.get_absolute_url(),
83-
thumbnail_url=image.thumbnail.url,
84-
)
85-
86-
def test_create_imageattachment_when_animated(self):
87-
"""
88-
An image attachment is created from an uploaded animated GIF file.
89-
90-
Verifies all appropriate fields are correctly set.
91-
"""
92-
filepath = "kitsune/upload/tests/media/animated.gif"
93-
with open(filepath, "rb") as f:
94-
up_file = File(f)
95-
file_info = create_imageattachment({"image": up_file}, self.user, self.obj)
96-
97-
image = ImageAttachment.objects.all()[0]
98-
check_file_info(
99-
file_info,
100-
name=filepath,
101-
width=120,
102-
height=120,
103-
delete_url=image.get_delete_url(),
104-
url=image.get_absolute_url(),
105-
thumbnail_url=image.thumbnail.url,
106-
)
107-
108-
109-
class FileNameTestCase(TestCase):
110-
def _match_file_name(self, name, name_end):
111-
assert name.endswith(name_end), '"{}" does not end with "{}"'.format(name, name_end)
112-
113-
def test_empty_file_name(self):
114-
self._match_file_name("", "")
115-
116-
def test_empty_file_name_with_extension(self):
117-
self._match_file_name(get_file_name(".wtf"), "3f8242")
118-
119-
def test_ascii(self):
120-
self._match_file_name(get_file_name("some ascii.jpg"), "5959e0.jpg")
121-
122-
def test_ascii_dir(self):
123-
self._match_file_name(get_file_name("dir1/dir2/some ascii.jpg"), "5959e0.jpg")
124-
125-
def test_low_unicode(self):
126-
self._match_file_name(get_file_name("157d9383e6aeba7180378fd8c1d46f80.gif"), "bdaf1a.gif")
127-
128-
def test_high_unicode(self):
129-
self._match_file_name(get_file_name("\u6709\u52b9.jpeg"), "ce1518.jpeg")
130-
131-
def test_full_mixed(self):
132-
self._match_file_name(
133-
get_file_name("123\xe5\xe5\xee\xe9\xf8\xe7\u6709\u52b9.png"), "686c11.png"
134-
)

kitsune/upload/tests/test_tasks.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
from kitsune.upload.tasks import (
1414
_scale_dimensions,
1515
compress_image,
16-
create_image_thumbnail,
1716
generate_thumbnail,
1817
)
18+
from kitsune.upload.utils import FileTooLargeError, create_image_thumbnail
1919
from kitsune.users.tests import UserFactory
2020

2121

@@ -74,6 +74,12 @@ def test_create_image_thumbnail_default(self):
7474
self.assertEqual(expected_thumb.width, actual_thumb.width)
7575
self.assertEqual(expected_thumb.height, actual_thumb.height)
7676

77+
@override_settings(IMAGE_MAX_PIXELS=1)
78+
def test_image_too_large(self):
79+
"""create_image_thumbnail raises FileTooLargeError for an oversized image."""
80+
with self.assertRaises(FileTooLargeError):
81+
create_image_thumbnail("kitsune/upload/tests/media/test.jpg")
82+
7783
def test_create_image_thumbnail_avatar(self):
7884
"""An avatar is created from an image file."""
7985
thumb_content = create_image_thumbnail(

0 commit comments

Comments
 (0)