emoji: Derive the file extension from a limited set of content-types.

We thumbnail and serve emoji with the same format as they were
uploaded.  However, we preserved the original extension, which might
mismatch with the provided content-type.

Limit the content-type to a subset which is both (a) an image format
we can thumbnail, and (b) a media format which is widely-enough
supported that we are willing to provide it to all browsers.  This
prevents uploading a `.tiff` emoji, for instance.

Based on this limited content-type, we then reverse to find the
reasonable extension to use when storing it.  This is particularly
important because the local file storage uses the file extension to
choose what content-type to re-serve the emoji as.

This does nothing for existing emoji, which may have odd or missing
file extensions.
This commit is contained in:
Alex Vandiver 2024-07-11 19:10:17 +00:00 committed by Tim Abbott
parent 62a0611ddb
commit f6b99171ce
9 changed files with 114 additions and 14 deletions

View File

@ -8,7 +8,9 @@ from django.utils.translation import gettext as _
from zerver.lib.emoji import get_emoji_file_name from zerver.lib.emoji import get_emoji_file_name
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.thumbnail import THUMBNAIL_ACCEPT_IMAGE_TYPES, BadImageError
from zerver.lib.upload import upload_emoji_image from zerver.lib.upload import upload_emoji_image
from zerver.lib.upload.base import INLINE_MIME_TYPES
from zerver.models import Realm, RealmAuditLog, RealmEmoji, UserProfile from zerver.models import Realm, RealmAuditLog, RealmEmoji, UserProfile
from zerver.models.realm_emoji import EmojiInfo, get_all_custom_emoji_for_realm from zerver.models.realm_emoji import EmojiInfo, get_all_custom_emoji_for_realm
from zerver.models.users import active_user_ids from zerver.models.users import active_user_ids
@ -31,7 +33,13 @@ def check_add_realm_emoji(
# Match the string in upload_emoji. # Match the string in upload_emoji.
raise JsonableError(_("A custom emoji with this name already exists.")) raise JsonableError(_("A custom emoji with this name already exists."))
emoji_file_name = get_emoji_file_name(image_file.name, realm_emoji.id) # This mirrors the check in upload_emoji_image because we want to
# have some reasonable guarantees on the content-type before
# deriving the file extension from it.
if content_type not in THUMBNAIL_ACCEPT_IMAGE_TYPES or content_type not in INLINE_MIME_TYPES:
raise BadImageError(_("Invalid image format"))
emoji_file_name = get_emoji_file_name(content_type, realm_emoji.id)
emoji_uploaded_successfully = False emoji_uploaded_successfully = False
is_animated = False is_animated = False

View File

@ -7,6 +7,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.mime_types import guess_extension
from zerver.lib.storage import static_path from zerver.lib.storage import static_path
from zerver.lib.upload import upload_backend from zerver.lib.upload import upload_backend
from zerver.models import Reaction, Realm, RealmEmoji, UserProfile from zerver.models import Reaction, Realm, RealmEmoji, UserProfile
@ -148,12 +149,9 @@ def get_emoji_url(emoji_file_name: str, realm_id: int, still: bool = False) -> s
return upload_backend.get_emoji_url(emoji_file_name, realm_id, still) return upload_backend.get_emoji_url(emoji_file_name, realm_id, still)
def get_emoji_file_name(emoji_file_name: str, emoji_id: int) -> str: def get_emoji_file_name(content_type: str, emoji_id: int) -> str:
image_ext = os.path.splitext(emoji_file_name)[1] image_ext = guess_extension(content_type, strict=False)
if not re.match(r"\.\w+$", image_ext): # The only callsite of this pre-limits the content_type to a
# Because the extension from the uploaded filename is # reasonable set that we know have extensions.
# user-provided, preserved in the output filename, and libvips assert image_ext is not None
# 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)) return "".join((str(emoji_id), image_ext))

View File

@ -1,5 +1,6 @@
import sys import sys
from mimetypes import add_type from mimetypes import add_type
from mimetypes import guess_extension as guess_extension
from mimetypes import guess_type as guess_type from mimetypes import guess_type as guess_type
add_type("audio/flac", ".flac") add_type("audio/flac", ".flac")

View File

@ -137,8 +137,9 @@ def resize_emoji(
raise BadImageError(_("Image size exceeds limit.")) raise BadImageError(_("Image size exceeds limit."))
# Square brackets are used for providing options to libvips' save # Square brackets are used for providing options to libvips' save
# operation; these should have been filtered out earlier, so we # operation; the extension on the filename comes from reversing
# assert none are found here, for safety. # the content-type, which removes most of the attacker control of
# this string, but assert it has no bracketed pieces for safety.
write_file_ext = os.path.splitext(emoji_file_name)[1] write_file_ext = os.path.splitext(emoji_file_name)[1]
assert "[" not in write_file_ext assert "[" not in write_file_ext

View File

@ -23,7 +23,7 @@ from zerver.lib.thumbnail import (
resize_avatar, resize_avatar,
resize_emoji, resize_emoji,
) )
from zerver.lib.upload.base import ZulipUploadBackend from zerver.lib.upload.base import INLINE_MIME_TYPES, ZulipUploadBackend
from zerver.models import Attachment, Message, Realm, RealmEmoji, ScheduledMessage, UserProfile from zerver.models import Attachment, Message, Realm, RealmEmoji, ScheduledMessage, UserProfile
from zerver.models.users import is_cross_realm_bot_email from zerver.models.users import is_cross_realm_bot_email
@ -340,6 +340,14 @@ def upload_emoji_image(
if backend is None: if backend is None:
backend = upload_backend backend = upload_backend
# Emoji are served in the format that they are uploaded, so must
# be _both_ an image format that we're willing to thumbnail, _and_
# a format which is widespread enough that we're willing to inline
# it. The latter contains non-image formats, but the former
# limits to only images.
if content_type not in THUMBNAIL_ACCEPT_IMAGE_TYPES or content_type not in INLINE_MIME_TYPES:
raise BadImageError(_("Invalid image format"))
emoji_path = RealmEmoji.PATH_ID_TEMPLATE.format( emoji_path = RealmEmoji.PATH_ID_TEMPLATE.format(
realm_id=user_profile.realm_id, realm_id=user_profile.realm_id,
emoji_file_name=emoji_file_name, emoji_file_name=emoji_file_name,

View File

@ -1039,7 +1039,9 @@ class TestRealmAuditLog(ZulipTestCase):
added_emoji = EmojiInfo( added_emoji = EmojiInfo(
id=str(realm_emoji.id), id=str(realm_emoji.id),
name="test_emoji", name="test_emoji",
source_url=get_emoji_url(get_emoji_file_name("img.png", realm_emoji.id), user.realm_id), source_url=get_emoji_url(
get_emoji_file_name("image/png", realm_emoji.id), user.realm_id
),
deactivated=False, deactivated=False,
author_id=user.id, author_id=user.id,
still_url=None, still_url=None,
@ -1067,7 +1069,9 @@ class TestRealmAuditLog(ZulipTestCase):
deactivated_emoji = EmojiInfo( deactivated_emoji = EmojiInfo(
id=str(realm_emoji.id), id=str(realm_emoji.id),
name="test_emoji", name="test_emoji",
source_url=get_emoji_url(get_emoji_file_name("img.png", realm_emoji.id), user.realm_id), source_url=get_emoji_url(
get_emoji_file_name("image/png", realm_emoji.id), user.realm_id
),
deactivated=True, deactivated=True,
author_id=user.id, author_id=user.id,
still_url=None, still_url=None,

View File

@ -319,6 +319,12 @@ class RealmEmojiTest(ZulipTestCase):
result = self.client_post("/json/realm/emoji/my_emoji", {"file": fp}) result = self.client_post("/json/realm/emoji/my_emoji", {"file": fp})
self.assert_json_error(result, "Uploaded file is larger than the allowed limit of 0 MiB") self.assert_json_error(result, "Uploaded file is larger than the allowed limit of 0 MiB")
def test_emoji_upload_file_format_error(self) -> None:
self.login("iago")
with get_test_image_file("img.tif") as fp:
result = self.client_post("/json/realm/emoji/my_emoji", {"file": fp})
self.assert_json_error(result, "Invalid image format")
def test_upload_already_existed_emoji(self) -> None: def test_upload_already_existed_emoji(self) -> None:
self.login("iago") self.login("iago")
with get_test_image_file("img.png") as fp1: with get_test_image_file("img.png") as fp1:

View File

@ -3,6 +3,7 @@ import re
import time import time
from io import StringIO from io import StringIO
from unittest import mock from unittest import mock
from unittest.mock import patch
from urllib.parse import quote from urllib.parse import quote
import orjson import orjson
@ -1753,6 +1754,54 @@ class RealmNightLogoTest(RealmLogoTest):
night = True night = True
class EmojiTest(UploadSerializeMixin, ZulipTestCase):
def test_upload_emoji(self) -> None:
self.login("iago")
with get_test_image_file("img.png") as f:
result = self.client_post("/json/realm/emoji/new", {"f1": f})
self.assert_json_success(result)
def test_non_image(self) -> None:
"""Non-image is not resized"""
self.login("iago")
with get_test_image_file("text.txt") as f:
with patch("zerver.lib.upload.resize_emoji", return_value=(b"a", None)) as resize_mock:
result = self.client_post("/json/realm/emoji/new", {"f1": f})
self.assert_json_error(result, "Invalid image format")
resize_mock.assert_not_called()
def test_upsupported_format(self) -> None:
"""Invalid format is not resized"""
self.login("iago")
with get_test_image_file("img.bmp") as f:
with patch("zerver.lib.upload.resize_emoji", return_value=(b"a", None)) as resize_mock:
result = self.client_post("/json/realm/emoji/new", {"f1": f})
self.assert_json_error(result, "Invalid image format")
resize_mock.assert_not_called()
def test_upload_big_after_animated_resize(self) -> None:
"""A big animated image is fine as long as the still is small"""
self.login("iago")
with get_test_image_file("animated_img.gif") as f:
with patch(
"zerver.lib.upload.resize_emoji", return_value=(b"a" * (200 * 1024), b"aaa")
) as resize_mock:
result = self.client_post("/json/realm/emoji/new", {"f1": f})
self.assert_json_success(result)
resize_mock.assert_called_once()
def test_upload_too_big_after_animated_resize_still(self) -> None:
"""Still of animated image is too big after resizing"""
self.login("iago")
with get_test_image_file("animated_img.gif") as f:
with patch(
"zerver.lib.upload.resize_emoji", return_value=(b"aaa", b"a" * (200 * 1024))
) as resize_mock:
result = self.client_post("/json/realm/emoji/new", {"f1": f})
self.assert_json_error(result, "Image size exceeds limit")
resize_mock.assert_called_once()
class SanitizeNameTests(ZulipTestCase): class SanitizeNameTests(ZulipTestCase):
def test_file_name(self) -> None: def test_file_name(self) -> None:
self.assertEqual(sanitize_name("test.txt"), "test.txt") self.assertEqual(sanitize_name("test.txt"), "test.txt")

View File

@ -23,7 +23,9 @@ from zerver.lib.thumbnail import (
DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE,
DEFAULT_EMOJI_SIZE, DEFAULT_EMOJI_SIZE,
MEDIUM_AVATAR_SIZE, MEDIUM_AVATAR_SIZE,
BadImageError,
resize_avatar, resize_avatar,
resize_emoji,
) )
from zerver.lib.upload import ( from zerver.lib.upload import (
all_message_attachments, all_message_attachments,
@ -492,6 +494,29 @@ class S3Test(ZulipTestCase):
self.assertEqual(DEFAULT_EMOJI_SIZE, resized_image.height) self.assertEqual(DEFAULT_EMOJI_SIZE, resized_image.height)
self.assertEqual(DEFAULT_EMOJI_SIZE, resized_image.width) self.assertEqual(DEFAULT_EMOJI_SIZE, resized_image.width)
@use_s3_backend
def test_upload_emoji_non_image(self) -> None:
create_s3_buckets(settings.S3_AVATAR_BUCKET)[0]
user_profile = self.example_user("hamlet")
emoji_name = "emoji.png"
with get_test_image_file("text.txt") as image_file:
with patch("zerver.lib.upload.resize_emoji", side_effect=resize_emoji) as resize_mock:
with self.assertRaises(BadImageError):
# We trust the content-type and fail when we try to load the image
zerver.lib.upload.upload_emoji_image(
image_file, emoji_name, user_profile, "image/png"
)
resize_mock.assert_called_once()
with patch("zerver.lib.upload.resize_emoji", side_effect=resize_emoji) as resize_mock:
with self.assertRaises(BadImageError):
# We trust the content-type and abort before trying to load
zerver.lib.upload.upload_emoji_image(
image_file, emoji_name, user_profile, "text/plain"
)
resize_mock.assert_not_called()
@use_s3_backend @use_s3_backend
def test_tarball_upload_and_deletion(self) -> None: def test_tarball_upload_and_deletion(self) -> None:
bucket = create_s3_buckets(settings.S3_AVATAR_BUCKET)[0] bucket = create_s3_buckets(settings.S3_AVATAR_BUCKET)[0]