diff --git a/avatar/forms.py b/avatar/forms.py index 012ab08..902ec39 100644 --- a/avatar/forms.py +++ b/avatar/forms.py @@ -8,46 +8,70 @@ from django.template.defaultfilters import filesizeformat from avatar.models import Avatar from avatar.settings import (AVATAR_MAX_AVATARS_PER_USER, AVATAR_MAX_SIZE, - AVATAR_ALLOWED_FILE_EXTS, AVATAR_DEFAULT_SIZE) + AVATAR_ALLOWED_FILE_EXTS, AVATAR_DEFAULT_SIZE, + AVATAR_ALLOWED_MIMETYPES) def avatar_img(avatar, size): if not avatar.thumbnail_exists(size): avatar.create_thumbnail(size) - return mark_safe("""%s""" % + return mark_safe("""%s""" % (avatar.avatar_url(size), unicode(avatar), size, size)) + class UploadAvatarForm(forms.Form): avatar = forms.ImageField(label=_(u"avatar")) - + def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') super(UploadAvatarForm, self).__init__(*args, **kwargs) - + def clean_avatar(self): data = self.cleaned_data['avatar'] + + if AVATAR_ALLOWED_MIMETYPES: + try: + import magic + except ImportError: + raise ImportError("python-magic library must be installed in order to use uploaded file content limitation") + + # Construct 256 bytes needed for mime validation + magic_buffer = "" + for chunk in data.chunks(): + magic_buffer += chunk + if len(magic_buffer) >= 256: + break + + # https://github.com/ahupp/python-magic#usage + mime = magic.from_buffer(magic_buffer, mime=True) + + if mime not in AVATAR_ALLOWED_MIMETYPES: + raise forms.ValidationError( + _(u"File content is invalid. Detected: %(mimetype)s Allowed content types are: %(valid_mime_list)s") % + {'valid_mime_list': ", ".join(AVATAR_ALLOWED_MIMETYPES), "mimetype": mime}) + if AVATAR_ALLOWED_FILE_EXTS: (root, ext) = os.path.splitext(data.name.lower()) if ext not in AVATAR_ALLOWED_FILE_EXTS: raise forms.ValidationError( - _(u"%(ext)s is an invalid file extension. Authorized extensions are : %(valid_exts_list)s") % - { 'ext' : ext, 'valid_exts_list' : ", ".join(AVATAR_ALLOWED_FILE_EXTS) }) + _(u"%(ext)s is an invalid file extension. Authorized extensions are : %(valid_exts_list)s") % + { 'ext' : ext, 'valid_exts_list' : ", ".join(AVATAR_ALLOWED_FILE_EXTS) }) if data.size > AVATAR_MAX_SIZE: raise forms.ValidationError( _(u"Your file is too big (%(size)s), the maximum allowed size is %(max_valid_size)s") % { 'size' : filesizeformat(data.size), 'max_valid_size' : filesizeformat(AVATAR_MAX_SIZE)} ) count = Avatar.objects.filter(user=self.user).count() if AVATAR_MAX_AVATARS_PER_USER > 1 and \ - count >= AVATAR_MAX_AVATARS_PER_USER: + count >= AVATAR_MAX_AVATARS_PER_USER: raise forms.ValidationError( _(u"You already have %(nb_avatars)d avatars, and the maximum allowed is %(nb_max_avatars)d.") % { 'nb_avatars' : count, 'nb_max_avatars' : AVATAR_MAX_AVATARS_PER_USER}) - return - + return + class PrimaryAvatarForm(forms.Form): - + def __init__(self, *args, **kwargs): user = kwargs.pop('user') size = kwargs.pop('size', AVATAR_DEFAULT_SIZE) diff --git a/avatar/settings.py b/avatar/settings.py index 12d5db2..8f696dd 100644 --- a/avatar/settings.py +++ b/avatar/settings.py @@ -24,3 +24,4 @@ AVATAR_ALLOWED_FILE_EXTS = getattr(settings, 'AVATAR_ALLOWED_FILE_EXTS', None) AVATAR_CACHE_TIMEOUT = getattr(settings, 'AVATAR_CACHE_TIMEOUT', 60 * 60) AVATAR_STORAGE = getattr(settings, 'AVATAR_STORAGE', settings.DEFAULT_FILE_STORAGE) AVATAR_CLEANUP_DELETED = getattr(settings, 'AVATAR_CLEANUP_DELETED', False) +AVATAR_ALLOWED_MIMETYPES = getattr(settings, 'AVATAR_ALLOWED_MIMETYPES', []) diff --git a/avatar/testdata/test.tiff b/avatar/testdata/test.tiff new file mode 100644 index 0000000..c3d899b Binary files /dev/null and b/avatar/testdata/test.tiff differ diff --git a/avatar/tests.py b/avatar/tests.py index 6d233d8..c3d29e2 100644 --- a/avatar/tests.py +++ b/avatar/tests.py @@ -13,7 +13,7 @@ try: dir(Image) # Placate PyFlakes except ImportError: import Image - + def upload_helper(o, filename): f = open(os.path.join(o.testdatapath, filename), "rb") @@ -24,7 +24,7 @@ def upload_helper(o, filename): return response class AvatarUploadTests(TestCase): - + def setUp(self): self.testdatapath = os.path.join(os.path.dirname(__file__), "testdata") self.user = User.objects.create_user('test', 'lennon@thebeatles.com', 'testpassword') @@ -36,7 +36,7 @@ class AvatarUploadTests(TestCase): response = upload_helper(self, "nonimagefile") self.failUnlessEqual(response.status_code, 200) self.failIfEqual(response.context['upload_avatar_form'].errors, {}) - + def testNormalImageUpload(self): response = upload_helper(self, "test.png") self.failUnlessEqual(response.status_code, 200) @@ -44,28 +44,35 @@ class AvatarUploadTests(TestCase): self.failUnlessEqual(response.context['upload_avatar_form'].errors, {}) avatar = get_primary_avatar(self.user) self.failIfEqual(avatar, None) - + + def testUnsupportedImageFormatUpload(self): + """ Check with python-magic that we detect corrupted / unapprovd image files correctly """ + response = upload_helper(self, "test.tiff") + self.failUnlessEqual(response.status_code, 200) + self.failUnlessEqual(len(response.redirect_chain), 0) # Redirect only if it worked + self.failIfEqual(response.context['upload_avatar_form'].errors, {}) + def testImageWithoutExtension(self): # use with AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png') response = upload_helper(self, "imagefilewithoutext") self.failUnlessEqual(response.status_code, 200) - self.failUnlessEqual(len(response.redirect_chain), 0) # Redirect only if it worked + self.failUnlessEqual(len(response.redirect_chain), 0) # Redirect only if it worked self.failIfEqual(response.context['upload_avatar_form'].errors, {}) - + def testImageWithWrongExtension(self): # use with AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png') response = upload_helper(self, "imagefilewithwrongext.ogg") self.failUnlessEqual(response.status_code, 200) - self.failUnlessEqual(len(response.redirect_chain), 0) # Redirect only if it worked + self.failUnlessEqual(len(response.redirect_chain), 0) # Redirect only if it worked self.failIfEqual(response.context['upload_avatar_form'].errors, {}) - + def testImageTooBig(self): # use with AVATAR_MAX_SIZE = 1024 * 1024 response = upload_helper(self, "testbig.png") self.failUnlessEqual(response.status_code, 200) - self.failUnlessEqual(len(response.redirect_chain), 0) # Redirect only if it worked + self.failUnlessEqual(len(response.redirect_chain), 0) # Redirect only if it worked self.failIfEqual(response.context['upload_avatar_form'].errors, {}) - + def testDefaultUrl(self): response = self.client.get(reverse('avatar_render_primary', kwargs={ 'user': self.user.username, @@ -81,13 +88,13 @@ class AvatarUploadTests(TestCase): def testNonExistingUser(self): a = get_primary_avatar("nonexistinguser") self.failUnlessEqual(a, None) - + def testThereCanBeOnlyOnePrimaryAvatar(self): for i in range(1, 10): self.testNormalImageUpload() count = Avatar.objects.filter(user=self.user, primary=True).count() self.failUnlessEqual(count, 1) - + def testDeleteAvatar(self): self.testNormalImageUpload() avatar = Avatar.objects.filter(user=self.user) @@ -99,7 +106,7 @@ class AvatarUploadTests(TestCase): self.failUnlessEqual(len(response.redirect_chain), 1) count = Avatar.objects.filter(user=self.user).count() self.failUnlessEqual(count, 0) - + def testDeletePrimaryAvatarAndNewPrimary(self): self.testThereCanBeOnlyOnePrimaryAvatar() primary = get_primary_avatar(self.user) @@ -116,7 +123,7 @@ class AvatarUploadTests(TestCase): def testTooManyAvatars(self): for i in range(0, AVATAR_MAX_AVATARS_PER_USER): self.testNormalImageUpload() - count_before = Avatar.objects.filter(user=self.user).count() + count_before = Avatar.objects.filter(user=self.user).count() response = upload_helper(self, "test.png") count_after = Avatar.objects.filter(user=self.user).count() self.failUnlessEqual(response.status_code, 200) @@ -129,5 +136,5 @@ class AvatarUploadTests(TestCase): # def testHashFileName # def testHashUserName # def testChangePrimaryAvatar - # def testDeleteThumbnailAndRecreation + # def testDeleteThumbnailAndRecreation # def testAutomaticThumbnailCreation diff --git a/docs/usage.txt b/docs/usage.txt index 327e8ba..6c38a95 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -10,7 +10,7 @@ that are required. A minimal integration can work like this: 1. List this application in the ``INSTALLED_APPS`` portion of your settings file. Your settings file will look something like:: - + INSTALLED_APPS = ( # ... 'avatar', @@ -18,7 +18,7 @@ that are required. A minimal integration can work like this: 2. Add the pagination urls to the end of your root urlconf. Your urlconf will look something like:: - + urlpatterns = patterns('', # ... (r'^admin/(.*)', admin.site.root), @@ -27,20 +27,20 @@ that are required. A minimal integration can work like this: 3. Somewhere in your template navigation scheme, link to the change avatar page:: - + Change your avatar 4. Wherever you want to display an avatar for a user, first load the avatar template tags:: - + {% load avatar_tags %} - + Then, use the ``avatar`` tag to display an avatar of a default size:: - + {% avatar user %} - + Or specify a size (in pixels) explicitly:: - + {% avatar user 65 %} 5. Optionally customize ``avatar/change.html`` and @@ -59,7 +59,7 @@ Changing an avatar The actual view function is located at ``avatar.views.change``, and this can be referenced by the url name ``avatar_change``. It takes two keyword arguments: ``extra_context`` and ``next_override``. If ``extra_context`` is -provided, that context will be placed into the template's context. +provided, that context will be placed into the template's context. If ``next_override`` is provided, the user will be redirected to the specified URL after form submission. Otherwise the user will be redirected to the URL @@ -128,6 +128,12 @@ AVATAR_DEFAULT_URL The default URL to default to if ``AVATAR_GRAVATAR_BACKUP`` is set to False and there is no ``Avatar`` instance found in the system for the given user. +AVATAR_ALLOWED_MIMETYPES + Limit allowed avatar image uploads by their actual content payload and what image codecs we wish to support. + This limits website user content site attack vectors against image codec buffer overflow and similar bugs. + `You must have python-imaging library installed `_. + Suggested safe setting: ``("image/png", "image/gif", "image/jpeg")``. + When enabled you'll get the following error on the form upload *File content is invalid. Detected: image/tiff Allowed content types are: image/png, image/gif, image/jpg*. Management Commands -------------------