Check that uploaded images are actually images

This commit is contained in:
Thomas Girod 2025-04-08 17:21:30 +02:00
parent 13f417ba30
commit 376af35bfb
5 changed files with 59 additions and 13 deletions

View File

@ -1,16 +1,29 @@
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated, Any
from annotated_types import MinLen from annotated_types import MinLen
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Q from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet 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 import AliasChoices, Field
from pydantic_core.core_schema import ValidationInfo
from core.models import Group, SithFile, User 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): class SimpleUserSchema(ModelSchema):

View File

@ -22,6 +22,7 @@ from typing import Final
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import ExifTags from PIL import ExifTags
@ -111,6 +112,18 @@ def get_semester_code(d: date | None = None) -> str:
return "P" + str(start.year)[-2:] 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( def resize_image(
im: Image, edge: int, img_format: str, *, optimize: bool = True im: Image, edge: int, img_format: str, *, optimize: bool = True
) -> ContentFile: ) -> ContentFile:

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -116,7 +116,7 @@ msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur"
msgid "You should specify a role" msgid "You should specify a role"
msgstr "Vous devez choisir un rôle" 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" msgid "You do not have the permission to do that"
msgstr "Vous n'avez pas la permission de faire cela" msgstr "Vous n'avez pas la permission de faire cela"
@ -1047,7 +1047,7 @@ msgid "Posters - edit"
msgstr "Affiche - modifier" msgstr "Affiche - modifier"
#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja #: 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" msgid "Create"
msgstr "Créer" msgstr "Créer"
@ -1644,6 +1644,10 @@ msgstr "étiquette"
msgid "operation type" msgid "operation type"
msgstr "type d'opération" 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 #: core/templates/core/403.jinja
msgid "403, Forbidden" msgid "403, Forbidden"
msgstr "403, Non autorisé" msgstr "403, Non autorisé"
@ -2729,7 +2733,7 @@ msgstr "Ajouter un nouveau dossier"
msgid "Error creating folder %(folder_name)s: %(msg)s" msgid "Error creating folder %(folder_name)s: %(msg)s"
msgstr "Erreur de création du dossier %(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 #, python-format
msgid "Error uploading file %(file_name)s: %(msg)s" msgid "Error uploading file %(file_name)s: %(msg)s"
msgstr "Erreur d'envoi du fichier %(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" msgid "Upload images"
msgstr "Envoyer les 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 #: sas/forms.py
msgid "You already requested moderation for this picture." msgid "You already requested moderation for this picture."
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo." msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."

View File

@ -2,7 +2,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import F from django.db.models import F
from django.urls import reverse from django.urls import reverse
from ninja import Body, Query, UploadedFile from ninja import Body, File, Query
from ninja.errors import HttpError from ninja.errors import HttpError
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.exceptions import NotFound, PermissionDenied
@ -19,6 +19,7 @@ from core.auth.api_permissions import (
IsRoot, IsRoot,
) )
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UploadedImage
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@ -106,7 +107,7 @@ class PicturesController(ControllerBase):
response={200: None, 409: dict[str, list[str]]}, response={200: None, 409: dict[str, list[str]]},
url_name="upload_picture", 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) album = self.get_object_or_exception(Album, pk=album_id)
user = self.context.request.user user = self.context.request.user
self_moderate = user.has_perm("sas.moderate_sasfile") self_moderate = user.has_perm("sas.moderate_sasfile")

View File

@ -266,3 +266,23 @@ def test_upload_picture(client: Client):
assert picture.file.name == "SAS/test album/img.png" assert picture.file.name == "SAS/test album/img.png"
assert picture.compressed.name == ".compressed/SAS/test album/img.webp" assert picture.compressed.name == ".compressed/SAS/test album/img.webp"
assert picture.thumbnail.name == ".thumbnails/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"
)