From b14a33c65989f511a7752df008bc4d1069d22557 Mon Sep 17 00:00:00 2001 From: Alex Vandiver Date: Wed, 12 Jun 2024 18:19:15 +0000 Subject: [PATCH] thumbnailing: Switch to libvips, from PIL/pillow. This is done in as much of a drop-in fashion as possible. Note that libvips does not support animated PNGs[^1], and as such this conversion removes support for them as emoji; however, libvips includes support for webp images, which future commits will take advantage of. This removes the MAX_EMOJI_GIF_SIZE limit, since that existed to work around bugs in Pillow. MAX_EMOJI_GIF_FILE_SIZE_BYTES is fixed to actually be 128KiB (not 128MiB, as it actually was), and is counted _after_ resizing, since the point is to limit the amount of data transfer to clients. [^1]: https://github.com/libvips/libvips/discussions/2000 --- zerver/actions/realm_emoji.py | 5 - zerver/lib/emoji.py | 8 +- zerver/lib/thumbnail.py | 221 ++++++++++---------- zerver/lib/upload/__init__.py | 14 +- zerver/tests/images/animated_large_img.png | Bin 26249 -> 0 bytes zerver/tests/test_transfer.py | 6 +- zerver/tests/test_upload.py | 53 +++-- zilencer/management/commands/populate_db.py | 2 +- 8 files changed, 157 insertions(+), 152 deletions(-) delete mode 100644 zerver/tests/images/animated_large_img.png diff --git a/zerver/actions/realm_emoji.py b/zerver/actions/realm_emoji.py index 35010cd92d..e94109dfb8 100644 --- a/zerver/actions/realm_emoji.py +++ b/zerver/actions/realm_emoji.py @@ -8,7 +8,6 @@ from django.utils.translation import gettext as _ from zerver.lib.emoji import get_emoji_file_name from zerver.lib.exceptions import JsonableError -from zerver.lib.pysa import mark_sanitized from zerver.lib.upload import upload_emoji_image from zerver.models import Realm, RealmAuditLog, RealmEmoji, UserProfile from zerver.models.realm_emoji import EmojiInfo, get_all_custom_emoji_for_realm @@ -34,10 +33,6 @@ def check_add_realm_emoji( emoji_file_name = get_emoji_file_name(image_file.name, realm_emoji.id) - # The only user-controlled portion of 'emoji_file_name' is an extension, - # which cannot contain '..' or '/' or '\', making it difficult to exploit - emoji_file_name = mark_sanitized(emoji_file_name) - emoji_uploaded_successfully = False is_animated = False try: diff --git a/zerver/lib/emoji.py b/zerver/lib/emoji.py index 7782fee314..fefb71906f 100644 --- a/zerver/lib/emoji.py +++ b/zerver/lib/emoji.py @@ -149,5 +149,11 @@ def get_emoji_url(emoji_file_name: str, realm_id: int, still: bool = False) -> s def get_emoji_file_name(emoji_file_name: str, emoji_id: int) -> str: - _, image_ext = os.path.splitext(emoji_file_name) + image_ext = os.path.splitext(emoji_file_name)[1] + if not re.match(r"\.\w+$", image_ext): + # Because the extension from the uploaded filename is + # user-provided, preserved in the output filename, and libvips + # uses `[...]` after the extension for options, we validate + # the simple file extension. + raise JsonableError(_("Bad file name!")) # nocoverage return "".join((str(emoji_id), image_ext)) diff --git a/zerver/lib/thumbnail.py b/zerver/lib/thumbnail.py index ba2afba8ff..ac3ce21ce2 100644 --- a/zerver/lib/thumbnail.py +++ b/zerver/lib/thumbnail.py @@ -1,11 +1,12 @@ -import io -from typing import Optional, Tuple +import logging +import os +from contextlib import contextmanager +from typing import Iterator, Optional, Tuple from urllib.parse import urljoin +import pyvips from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext as _ -from PIL import GifImagePlugin, Image, ImageOps, PngImagePlugin -from PIL.Image import DecompressionBombError from zerver.lib.camo import get_camo_url from zerver.lib.exceptions import ErrorCode, JsonableError @@ -14,11 +15,11 @@ DEFAULT_AVATAR_SIZE = 100 MEDIUM_AVATAR_SIZE = 500 DEFAULT_EMOJI_SIZE = 64 -# These sizes were selected based on looking at the maximum common -# sizes in a library of animated custom emoji, balanced against the -# network cost of very large emoji images. -MAX_EMOJI_GIF_SIZE = 128 -MAX_EMOJI_GIF_FILE_SIZE_BYTES = 128 * 1024 * 1024 # 128 kb +# We refuse to deal with any image whose total pixelcount exceeds this. +IMAGE_BOMB_TOTAL_PIXELS = 90000000 + +# Reject emoji which, after resizing, have stills larger than this +MAX_EMOJI_GIF_FILE_SIZE_BYTES = 128 * 1024 # 128 kb class BadImageError(JsonableError): @@ -39,122 +40,114 @@ def generate_thumbnail_url(path: str, size: str = "0x0") -> str: return get_camo_url(path) -def resize_avatar(image_data: bytes, size: int = DEFAULT_AVATAR_SIZE) -> bytes: +@contextmanager +def libvips_check_image(image_data: bytes) -> Iterator[pyvips.Image]: + # The primary goal of this is to verify that the image is valid, + # and raise BadImageError otherwise. The yielded `source_image` + # may be ignored, since calling `thumbnail_buffer` is faster than + # calling `thumbnail_image` on a pyvips.Image, since the latter + # cannot make use of shrink-on-load optimizations: + # https://www.libvips.org/API/current/libvips-resample.html#vips-thumbnail-image try: - im = Image.open(io.BytesIO(image_data)) - im = ImageOps.exif_transpose(im) - im = ImageOps.fit(im, (size, size), Image.Resampling.LANCZOS) - except OSError: + source_image = pyvips.Image.new_from_buffer(image_data, "") + except pyvips.Error: raise BadImageError(_("Could not decode image; did you upload an image file?")) - except DecompressionBombError: + + if source_image.width * source_image.height > IMAGE_BOMB_TOTAL_PIXELS: raise BadImageError(_("Image size exceeds limit.")) - out = io.BytesIO() - if im.mode == "CMYK": - im = im.convert("RGB") - im.save(out, format="png") - return out.getvalue() + + try: + yield source_image + except pyvips.Error as e: # nocoverage + logging.exception(e) + raise BadImageError(_("Bad image!")) + + +def resize_avatar(image_data: bytes, size: int = DEFAULT_AVATAR_SIZE) -> bytes: + # This will scale up, if necessary, and will scale the smallest + # dimension to fit. That is, a 1x1000 image will end up with the + # one middle pixel enlarged to fill the full square. + with libvips_check_image(image_data): + return pyvips.Image.thumbnail_buffer( + image_data, + size, + height=size, + crop=pyvips.Interesting.CENTRE, + ).write_to_buffer(".png") def resize_logo(image_data: bytes) -> bytes: - try: - im = Image.open(io.BytesIO(image_data)) - im = ImageOps.exif_transpose(im) - im.thumbnail((8 * DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), Image.Resampling.LANCZOS) - except OSError: - raise BadImageError(_("Could not decode image; did you upload an image file?")) - except DecompressionBombError: - raise BadImageError(_("Image size exceeds limit.")) - out = io.BytesIO() - if im.mode == "CMYK": - im = im.convert("RGB") - im.save(out, format="png") - return out.getvalue() - - -def resize_animated(im: Image.Image, size: int = DEFAULT_EMOJI_SIZE) -> bytes: - assert im.n_frames > 1 - frames = [] - duration_info = [] - disposals = [] - # If 'loop' info is not set then loop for infinite number of times. - loop = im.info.get("loop", 0) - for frame_num in range(im.n_frames): - im.seek(frame_num) - new_frame = im.copy() - new_frame.paste(im, (0, 0), im.convert("RGBA")) - new_frame = ImageOps.pad(new_frame, (size, size), Image.Resampling.LANCZOS) - frames.append(new_frame) - if im.info.get("duration") is None: # nocoverage - raise BadImageError(_("Corrupt animated image.")) - duration_info.append(im.info["duration"]) - if isinstance(im, GifImagePlugin.GifImageFile): - disposals.append( - im.disposal_method # type: ignore[attr-defined] # private member missing from stubs - ) - elif isinstance(im, PngImagePlugin.PngImageFile): - disposals.append(im.info.get("disposal", PngImagePlugin.Disposal.OP_NONE)) - else: # nocoverage - raise BadImageError(_("Unknown animated image format.")) - out = io.BytesIO() - frames[0].save( - out, - save_all=True, - optimize=False, - format=im.format, - append_images=frames[1:], - duration=duration_info, - disposal=disposals, - loop=loop, - ) - - return out.getvalue() + # This will only scale the image down, and will resize it to + # preserve aspect ratio and be contained within 8*AVATAR by AVATAR + # pixels; it does not add any padding to make it exactly that + # size. A 1000x10 pixel image will end up as 800x8; a 10x10 will + # end up 10x10. + with libvips_check_image(image_data): + return pyvips.Image.thumbnail_buffer( + image_data, + 8 * DEFAULT_AVATAR_SIZE, + height=DEFAULT_AVATAR_SIZE, + size=pyvips.Size.DOWN, + ).write_to_buffer(".png") def resize_emoji( - image_data: bytes, size: int = DEFAULT_EMOJI_SIZE + image_data: bytes, emoji_file_name: str, size: int = DEFAULT_EMOJI_SIZE ) -> Tuple[bytes, bool, Optional[bytes]]: + if len(image_data) > MAX_EMOJI_GIF_FILE_SIZE_BYTES: + raise BadImageError(_("Image size exceeds limit.")) + + # Square brackets are used for providing options to libvips' save + # operation; these should have been filtered out earlier, so we + # assert none are found here, for safety. + write_file_ext = os.path.splitext(emoji_file_name)[1] + assert "[" not in write_file_ext + # This function returns three values: # 1) Emoji image data. - # 2) If emoji is gif i.e. animated. - # 3) If is animated then return still image data i.e. first frame of gif. - - try: - im = Image.open(io.BytesIO(image_data)) - image_format = im.format - if getattr(im, "n_frames", 1) > 1: - # There are a number of bugs in Pillow which cause results - # in resized images being broken. To work around this we - # only resize under certain conditions to minimize the - # chance of creating ugly images. - should_resize = ( - im.size[0] != im.size[1] # not square - or im.size[0] > MAX_EMOJI_GIF_SIZE # dimensions too large - or len(image_data) > MAX_EMOJI_GIF_FILE_SIZE_BYTES # filesize too large + # 2) If the emoji is animated. + # 3) If it is animated, the still image data i.e. first frame of gif. + with libvips_check_image(image_data) as source_image: + if source_image.get_n_pages() == 1: + return ( + pyvips.Image.thumbnail_buffer( + image_data, + size, + height=size, + crop=pyvips.Interesting.CENTRE, + ).write_to_buffer(write_file_ext), + False, + None, ) + first_still = pyvips.Image.thumbnail_buffer( + image_data, + size, + height=size, + crop=pyvips.Interesting.CENTRE, + ).write_to_buffer(".png") - # Generate a still image from the first frame. Since - # we're converting the format to PNG anyway, we resize unconditionally. - still_image = im.copy() - still_image.seek(0) - still_image = ImageOps.exif_transpose(still_image) - still_image = ImageOps.fit(still_image, (size, size), Image.Resampling.LANCZOS) - out = io.BytesIO() - still_image.save(out, format="PNG") - still_image_data = out.getvalue() - - if should_resize: - image_data = resize_animated(im, size) - - return image_data, True, still_image_data - else: - # Note that this is essentially duplicated in the - # still_image code path, above. - im = ImageOps.exif_transpose(im) - im = ImageOps.fit(im, (size, size), Image.Resampling.LANCZOS) - out = io.BytesIO() - im.save(out, format=image_format) - return out.getvalue(), False, None - except OSError: - raise BadImageError(_("Could not decode image; did you upload an image file?")) - except DecompressionBombError: - raise BadImageError(_("Image size exceeds limit.")) + animated = pyvips.Image.thumbnail_buffer( + image_data, + size, + height=size, + # This is passed to the loader, and means "load all + # frames", instead of the default of just the first + option_string="n=-1", + ) + if animated.width != animated.get("page-height"): + # If the image is non-square, we have to iterate the + # frames to add padding to make it so + if not animated.hasalpha(): + animated = animated.addalpha() + frames = [ + frame.gravity( + pyvips.CompassDirection.CENTRE, + size, + size, + extend=pyvips.Extend.BACKGROUND, + background=[0, 0, 0, 0], + ) + for frame in animated.pagesplit() + ] + animated = frames[0].pagejoin(frames[1:]) + return (animated.write_to_buffer(write_file_ext), True, first_still) diff --git a/zerver/lib/upload/__init__.py b/zerver/lib/upload/__init__.py index 1770a12d3b..f159c9ad22 100644 --- a/zerver/lib/upload/__init__.py +++ b/zerver/lib/upload/__init__.py @@ -13,7 +13,13 @@ from zerver.lib.avatar_hash import user_avatar_path from zerver.lib.exceptions import ErrorCode, JsonableError from zerver.lib.mime_types import guess_type from zerver.lib.outgoing_http import OutgoingSession -from zerver.lib.thumbnail import MEDIUM_AVATAR_SIZE, resize_avatar, resize_emoji +from zerver.lib.thumbnail import ( + MAX_EMOJI_GIF_FILE_SIZE_BYTES, + MEDIUM_AVATAR_SIZE, + BadImageError, + resize_avatar, + resize_emoji, +) from zerver.lib.upload.base import ZulipUploadBackend from zerver.models import Attachment, Message, Realm, RealmEmoji, ScheduledMessage, UserProfile @@ -252,7 +258,11 @@ def upload_emoji_image( backend.upload_single_emoji_image( f"{emoji_path}.original", content_type, user_profile, image_data ) - resized_image_data, is_animated, still_image_data = resize_emoji(image_data) + resized_image_data, is_animated, still_image_data = resize_emoji(image_data, emoji_file_name) + if is_animated and len(still_image_data) > MAX_EMOJI_GIF_FILE_SIZE_BYTES: # nocoverage + raise BadImageError(_("Image size exceeds limit")) + if not is_animated and len(image_data) > MAX_EMOJI_GIF_FILE_SIZE_BYTES: # nocoverage + raise BadImageError(_("Image size exceeds limit")) backend.upload_single_emoji_image(emoji_path, content_type, user_profile, resized_image_data) if is_animated: assert still_image_data is not None diff --git a/zerver/tests/images/animated_large_img.png b/zerver/tests/images/animated_large_img.png deleted file mode 100644 index 841271dd171d0f999f8d0db1d0bb02dcfb1f57d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26249 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

mIV;|A;h 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 R5&ymMtbwV1p{{l4{{ZW+tgrw8 diff --git a/zerver/tests/test_transfer.py b/zerver/tests/test_transfer.py index bdf10e12be..33b045e4ae 100644 --- a/zerver/tests/test_transfer.py +++ b/zerver/tests/test_transfer.py @@ -103,7 +103,7 @@ class TransferUploadsToS3Test(ZulipTestCase): resized_key = bucket.Object(emoji_path) image_data = read_test_image_file("img.png") - resized_image_data, is_animated, still_image_data = resize_emoji(image_data) + resized_image_data, is_animated, still_image_data = resize_emoji(image_data, "img.png") self.assertEqual(is_animated, False) self.assertEqual(still_image_data, None) @@ -135,7 +135,9 @@ class TransferUploadsToS3Test(ZulipTestCase): ) image_data = read_test_image_file("animated_img.gif") - resized_image_data, is_animated, still_image_data = resize_emoji(image_data) + resized_image_data, is_animated, still_image_data = resize_emoji( + image_data, "animated_img.gif" + ) self.assertEqual(is_animated, True) self.assertEqual(type(still_image_data), bytes) diff --git a/zerver/tests/test_upload.py b/zerver/tests/test_upload.py index bbb8f34fe4..8a0667f570 100644 --- a/zerver/tests/test_upload.py +++ b/zerver/tests/test_upload.py @@ -1392,7 +1392,7 @@ class EmojiTest(UploadSerializeMixin, ZulipTestCase): # Test unequal width and height of animated GIF image animated_unequal_img_data = read_test_image_file("animated_unequal_img.gif") resized_img_data, is_animated, still_img_data = resize_emoji( - animated_unequal_img_data, size=50 + animated_unequal_img_data, "animated_unequal_img.gif", size=50 ) im = Image.open(io.BytesIO(resized_img_data)) self.assertEqual((50, 50), im.size) @@ -1404,37 +1404,34 @@ class EmojiTest(UploadSerializeMixin, ZulipTestCase): # Test corrupt image exception corrupted_img_data = read_test_image_file("corrupt.gif") with self.assertRaises(BadImageError): - resize_emoji(corrupted_img_data) + resize_emoji(corrupted_img_data, "corrupt.gif") - def test_resize(size: int = 50) -> None: - resized_img_data, is_animated, still_img_data = resize_emoji( - animated_large_img_data, size=50 - ) - im = Image.open(io.BytesIO(resized_img_data)) - self.assertEqual((size, size), im.size) - self.assertTrue(is_animated) - assert still_img_data - still_image = Image.open(io.BytesIO(still_img_data)) - self.assertEqual((50, 50), still_image.size) + animated_large_img_data = read_test_image_file("animated_large_img.gif") + resized_img_data, is_animated, still_img_data = resize_emoji( + animated_large_img_data, "animated_large_img.gif", size=50 + ) + im = Image.open(io.BytesIO(resized_img_data)) + self.assertEqual((50, 50), im.size) + self.assertTrue(is_animated) + assert still_img_data + still_image = Image.open(io.BytesIO(still_img_data)) + self.assertEqual((50, 50), still_image.size) - for img_format in ("gif", "png"): - animated_large_img_data = read_test_image_file(f"animated_large_img.{img_format}") + # Test an image file with too many bytes is not resized + with patch("zerver.lib.thumbnail.MAX_EMOJI_GIF_FILE_SIZE_BYTES", 1024): + with self.assertRaises(BadImageError): + resize_emoji(animated_large_img_data, "animated_large_img.gif", size=50) - # Test an image larger than max is resized - with patch("zerver.lib.thumbnail.MAX_EMOJI_GIF_SIZE", 128): - test_resize() - - # Test an image file larger than max is resized - with patch("zerver.lib.thumbnail.MAX_EMOJI_GIF_FILE_SIZE_BYTES", 3 * 1024 * 1024): - test_resize() - - # Test an image smaller than max and smaller than file size max is not resized - with patch("zerver.lib.thumbnail.MAX_EMOJI_GIF_SIZE", 512): - test_resize(size=256) + # Test an image file with too many pixels is not resized + with patch("zerver.lib.thumbnail.IMAGE_BOMB_TOTAL_PIXELS", 100): + with self.assertRaises(BadImageError): + resize_emoji(animated_large_img_data, "animated_large_img.gif", size=50) # Test a non-animated GIF image which does need to be resized still_large_img_data = read_test_image_file("still_large_img.gif") - resized_img_data, is_animated, no_still_data = resize_emoji(still_large_img_data, size=50) + resized_img_data, is_animated, no_still_data = resize_emoji( + still_large_img_data, "still_large_img.gif", size=50 + ) im = Image.open(io.BytesIO(resized_img_data)) self.assertEqual((50, 50), im.size) self.assertFalse(is_animated) @@ -1442,7 +1439,9 @@ class EmojiTest(UploadSerializeMixin, ZulipTestCase): # Test a non-animated and non-animatable image format which needs to be resized still_large_img_data = read_test_image_file("img.jpg") - resized_img_data, is_animated, no_still_data = resize_emoji(still_large_img_data, size=50) + resized_img_data, is_animated, no_still_data = resize_emoji( + still_large_img_data, "img.jpg", size=50 + ) im = Image.open(io.BytesIO(resized_img_data)) self.assertEqual((50, 50), im.size) self.assertFalse(is_animated) diff --git a/zilencer/management/commands/populate_db.py b/zilencer/management/commands/populate_db.py index f0d87a9202..8b6f6ee40f 100644 --- a/zilencer/management/commands/populate_db.py +++ b/zilencer/management/commands/populate_db.py @@ -913,7 +913,7 @@ class Command(ZulipBaseCommand): # Create a test realm emoji. IMAGE_FILE_PATH = static_path("images/test-images/checkbox.png") with open(IMAGE_FILE_PATH, "rb") as fp: - check_add_realm_emoji(zulip_realm, "green_tick", iago, File(fp)) + check_add_realm_emoji(zulip_realm, "green_tick", iago, File(fp, name="checkbox.png")) if not options["test_suite"]: # Populate users with some bar data