From 376af35bfb78b399cf1305192574f55958f3be25 Mon Sep 17 00:00:00 2001 From: Thomas Girod Date: Tue, 8 Apr 2025 17:21:30 +0200 Subject: [PATCH] Check that uploaded images are actually images --- core/schemas.py | 17 +++++++++++++++-- core/utils.py | 13 +++++++++++++ locale/fr/LC_MESSAGES/django.po | 17 ++++++++--------- sas/api.py | 5 +++-- sas/tests/test_api.py | 20 ++++++++++++++++++++ 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/core/schemas.py b/core/schemas.py index f4080c90..64494ed8 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -1,16 +1,29 @@ from pathlib import Path -from typing import Annotated +from typing import Annotated, Any from annotated_types import MinLen from django.contrib.staticfiles.storage import staticfiles_storage from django.db.models import Q from django.urls import reverse from django.utils.text import slugify +from django.utils.translation import gettext as _ from haystack.query import SearchQuerySet -from ninja import FilterSchema, ModelSchema, Schema +from ninja import FilterSchema, ModelSchema, Schema, UploadedFile from pydantic import AliasChoices, Field +from pydantic_core.core_schema import ValidationInfo from core.models import Group, SithFile, User +from core.utils import is_image + + +class UploadedImage(UploadedFile): + @classmethod + def _validate(cls, v: Any, info: ValidationInfo) -> Any: + super()._validate(v, info) + if not is_image(v): + msg = _("This file is not a valid image") + raise ValueError(msg) + return v class SimpleUserSchema(ModelSchema): diff --git a/core/utils.py b/core/utils.py index d9e8f180..c54aad81 100644 --- a/core/utils.py +++ b/core/utils.py @@ -22,6 +22,7 @@ from typing import Final import PIL from django.conf import settings from django.core.files.base import ContentFile +from django.core.files.uploadedfile import UploadedFile from django.http import HttpRequest from django.utils.timezone import localdate from PIL import ExifTags @@ -111,6 +112,18 @@ def get_semester_code(d: date | None = None) -> str: return "P" + str(start.year)[-2:] +def is_image(file: UploadedFile): + try: + im = PIL.Image.open(file.file) + im.verify() + # go back to the start of the file, without closing it. + # Otherwise, further checks on django side will fail + file.seek(0) + except PIL.UnidentifiedImageError: + return False + return True + + def resize_image( im: Image, edge: int, img_format: str, *, optimize: bool = True ) -> ContentFile: diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1e944299..249eee49 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-06 15:54+0200\n" +"POT-Creation-Date: 2025-04-08 16:20+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -116,7 +116,7 @@ msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur" msgid "You should specify a role" msgstr "Vous devez choisir un rôle" -#: club/forms.py sas/views.py +#: club/forms.py sas/forms.py msgid "You do not have the permission to do that" msgstr "Vous n'avez pas la permission de faire cela" @@ -1047,7 +1047,7 @@ msgid "Posters - edit" msgstr "Affiche - modifier" #: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja -#: sas/templates/sas/main.jinja +#: sas/templates/sas/fragments/album_create_form.jinja msgid "Create" msgstr "Créer" @@ -1644,6 +1644,10 @@ msgstr "étiquette" msgid "operation type" msgstr "type d'opération" +#: core/schemas.py +msgid "This file is not a valid image" +msgstr "Ce fichier n'est pas une image valide" + #: core/templates/core/403.jinja msgid "403, Forbidden" msgstr "403, Non autorisé" @@ -2729,7 +2733,7 @@ msgstr "Ajouter un nouveau dossier" msgid "Error creating folder %(folder_name)s: %(msg)s" msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s" -#: core/views/files.py core/views/forms.py sas/forms.py +#: core/views/files.py core/views/forms.py #, python-format msgid "Error uploading file %(file_name)s: %(msg)s" msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" @@ -4715,11 +4719,6 @@ msgstr "Ajouter un nouvel album" msgid "Upload images" msgstr "Envoyer les images" -#: sas/forms.py -#, python-format -msgid "Error creating album %(album)s: %(msg)s" -msgstr "Erreur de création de l'album %(album)s : %(msg)s" - #: sas/forms.py msgid "You already requested moderation for this picture." msgstr "Vous avez déjà déposé une demande de retrait pour cette photo." diff --git a/sas/api.py b/sas/api.py index 2d6d64ef..3cd03568 100644 --- a/sas/api.py +++ b/sas/api.py @@ -2,7 +2,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db.models import F from django.urls import reverse -from ninja import Body, Query, UploadedFile +from ninja import Body, File, Query from ninja.errors import HttpError from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound, PermissionDenied @@ -19,6 +19,7 @@ from core.auth.api_permissions import ( IsRoot, ) from core.models import Notification, User +from core.schemas import UploadedImage from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( AlbumAutocompleteSchema, @@ -106,7 +107,7 @@ class PicturesController(ControllerBase): response={200: None, 409: dict[str, list[str]]}, url_name="upload_picture", ) - def upload_picture(self, album_id: Body[int], picture: UploadedFile): + def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]): album = self.get_object_or_exception(Album, pk=album_id) user = self.context.request.user self_moderate = user.has_perm("sas.moderate_sasfile") diff --git a/sas/tests/test_api.py b/sas/tests/test_api.py index 2570a3c6..6c074dd0 100644 --- a/sas/tests/test_api.py +++ b/sas/tests/test_api.py @@ -266,3 +266,23 @@ def test_upload_picture(client: Client): assert picture.file.name == "SAS/test album/img.png" assert picture.compressed.name == ".compressed/SAS/test album/img.webp" assert picture.thumbnail.name == ".thumbnails/SAS/test album/img.webp" + + +@pytest.mark.django_db +def test_upload_invalid_picture(client: Client): + sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID) + album = baker.make(Album, is_in_sas=True, parent=sas, name="test album") + user = baker.make(User, is_superuser=True) + client.force_login(user) + file = SimpleUploadedFile( + name="file.txt", + content=b"azerty", + content_type="image/png", # the server shouldn't blindly trust the content_type + ) + res = client.post( + reverse("api:upload_picture"), {"album_id": album.id, "picture": file} + ) + assert res.status_code == 422 + assert res.json()["detail"][0]["ctx"]["error"] == ( + "Ce fichier n'est pas une image valide" + )