Security fix for image rotations. Add proper permissions, tests and use a form to avoid cross domain forgery attacks

This commit is contained in:
2026-04-25 01:06:23 +02:00
parent 0360d53cd6
commit 8a2eee113a
8 changed files with 263 additions and 99 deletions
+82 -69
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-23 22:21+0100\n" "POT-Creation-Date: 2026-04-25 01:00+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"
@@ -181,6 +181,22 @@ msgstr "Vous devez être cotisant pour faire partie d'un club"
msgid "You are already a member of this club" msgid "You are already a member of this club"
msgstr "Vous êtes déjà membre de ce club." msgstr "Vous êtes déjà membre de ce club."
#: club/forms.py
msgid "Club status"
msgstr "État du club"
#: club/forms.py
msgid "Active"
msgstr "Actif"
#: club/forms.py
msgid "Inactive"
msgstr "Inactif"
#: club/forms.py
msgid "All clubs"
msgstr "Tous les clubs"
#: club/models.py #: club/models.py
msgid "slug name" msgid "slug name"
msgstr "nom slug" msgstr "nom slug"
@@ -301,37 +317,22 @@ msgstr "Cet email est déjà abonné à cette mailing"
msgid "Unregistered user" msgid "Unregistered user"
msgstr "Utilisateur non enregistré" msgstr "Utilisateur non enregistré"
#: club/templates/club/club_list.jinja
msgid "Club list"
msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM." msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM" msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja
msgid "Club list"
msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja
msgid "Filters" msgid "Filters"
msgstr "Filtres" msgstr "Filtres"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja core/templates/core/base/header.jinja
msgid "Name" #: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja
msgstr "Nom" msgid "Search"
msgstr "Recherche"
#: club/templates/club/club_list.jinja
msgid "Club state"
msgstr "Etat du club"
#: club/templates/club/club_list.jinja
msgid "Active"
msgstr "Actif"
#: club/templates/club/club_list.jinja
msgid "Inactive"
msgstr "Inactif"
#: club/templates/club/club_list.jinja
msgid "All clubs"
msgstr "Tous les clubs"
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja #: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
msgid "New club" msgid "New club"
@@ -1863,11 +1864,6 @@ msgstr "Connexion"
msgid "Register" msgid "Register"
msgstr "Inscription" msgstr "Inscription"
#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja
#: matmat/templates/matmat/search_form.jinja
msgid "Search"
msgstr "Recherche"
#: core/templates/core/base/header.jinja #: core/templates/core/base/header.jinja
msgid "Logout" msgid "Logout"
msgstr "Déconnexion" msgstr "Déconnexion"
@@ -4212,6 +4208,47 @@ msgstr ""
msgid "this page" msgid "this page"
msgstr "cette page" msgstr "cette page"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale" msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente" msgstr "Aucun article n'est disponible à la vente"
@@ -4985,6 +5022,14 @@ msgstr "Envoyer les images"
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."
#: sas/forms.py
msgid "Left"
msgstr ""
#: sas/forms.py
msgid "Right"
msgstr "Droite"
#: sas/models.py #: sas/models.py
msgid "picture" msgid "picture"
msgstr "photo" msgstr "photo"
@@ -5106,6 +5151,14 @@ msgstr "Photos de %(user_name)s"
msgid "Download all my pictures" msgid "Download all my pictures"
msgstr "Télécharger toutes mes photos" msgstr "Télécharger toutes mes photos"
#: sas/views.py
msgid ""
"Newly rotated image might not be immediately displayed due to your web "
"browser's cache"
msgstr ""
"Les images nouvellements pivotées peuvent ne pas s'afficher immédiatement "
"à cause du cache de votre navigateur internet"
#: sith/settings.py #: sith/settings.py
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
@@ -5638,10 +5691,6 @@ msgstr "fin"
msgid "Moderate Trombi comments" msgid "Moderate Trombi comments"
msgstr "Modérer les commentaires du Trombi" msgstr "Modérer les commentaires du Trombi"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: trombi/templates/trombi/comment_moderation.jinja #: trombi/templates/trombi/comment_moderation.jinja
msgid "Reject" msgid "Reject"
msgstr "Refuser" msgstr "Refuser"
@@ -5883,39 +5932,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
+7
View File
@@ -90,3 +90,10 @@ class PictureModerationRequestForm(forms.ModelForm):
self.instance.author = self.user self.instance.author = self.user
self.instance.picture = self.picture self.instance.picture = self.picture
return super().save(commit) return super().save(commit)
class PictureRotationForm(forms.Form):
picture = forms.ModelChoiceField(Picture.objects.all(), required=True)
direction = forms.ChoiceField(
choices=[("LEFT", _("Left")), ("RIGHT", _("Right"))], required=True
)
+14 -14
View File
@@ -139,20 +139,20 @@ class Picture(SasFile):
self.compressed.name = new_extension_name self.compressed.name = new_extension_name
def rotate(self, degree): def rotate(self, degree):
for attr in ["file", "compressed", "thumbnail"]: im = Image.open(BytesIO(self.file.read()))
name = self.__getattribute__(attr).name self.file.seek(0)
with open(settings.MEDIA_ROOT / name, "r+b") as file: with open(self.file.path, "r+b") as f:
if file: im = im.rotate(degree, expand=True)
im = Image.open(BytesIO(file.read())) im.save(
file.seek(0) fp=f,
im = im.rotate(degree, expand=True) format=self.mime_type.split("/")[-1].upper(),
im.save( quality=90,
fp=file, optimize=True,
format=self.mime_type.split("/")[-1].upper(), progressive=True,
quality=90, )
optimize=True, self.file.seek(0)
progressive=True, self.generate_thumbnails(overwrite=True)
) self.save()
def get_next(self): def get_next(self):
if self.is_moderated: if self.is_moderated:
+20 -5
View File
@@ -191,7 +191,9 @@
} }
>form { >form {
input, .ts-wrapper {
input,
.ts-wrapper {
min-width: 100%; min-width: 100%;
max-width: 100%; max-width: 100%;
margin: 5px; margin: 5px;
@@ -212,22 +214,27 @@
flex-direction: column; flex-direction: column;
} }
.infos, .tools { .infos,
.tools {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .5em; gap: .5em;
@media (min-width: 700px) { @media (min-width: 700px) {
max-width: 350px; max-width: 350px;
} }
} }
.infos > div, .tools > div > div {
.infos>div,
.tools>div>div {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .35em; gap: .35em;
} }
.tools > div, >.infos >div>div { .tools>div,
>.infos>div>div {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@@ -237,7 +244,15 @@
flex: 1; flex: 1;
>div>div { >div>div {
>a.btn {
>form {
margin: 0;
padding: 0;
display: flex;
}
>a.btn,
>form>button {
background-color: $primary-neutral-light-color; background-color: $primary-neutral-light-color;
display: flex; display: flex;
justify-content: center; justify-content: center;
+18 -3
View File
@@ -122,7 +122,8 @@
{% trans %}Ask for removal{% endtrans %} {% trans %}Ask for removal{% endtrans %}
</a> </a>
</div> </div>
<div class="buttons"> <div class="buttons"
>
<a <a
class="btn btn-no-text" class="btn btn-no-text"
:href="currentPicture.edit_url" :href="currentPicture.edit_url"
@@ -130,8 +131,22 @@
> >
<i class="fa-regular fa-pen-to-square edit-action"></i> <i class="fa-regular fa-pen-to-square edit-action"></i>
</a> </a>
<a class="btn btn-no-text" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a> <form method="post" action="{{ url("sas:picture_rotate") }}"
<a class="btn btn-no-text" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a> x-show="{{ user.has_perm("sas.change_sasfile")|tojson}}"
>
{% csrf_token %}
<input type="hidden" name="picture" :value="currentPicture.id">
<input type="hidden" name="direction" value="LEFT">
<button><i class="fa-solid fa-rotate-left"></i></button>
</form>
<form method="post" action="{{ url("sas:picture_rotate") }}"
x-show="{{ user.has_perm("sas.change_sasfile")|tojson}}"
>
{% csrf_token %}
<input type="hidden" name="picture" :value="currentPicture.id">
<input type="hidden" name="direction" value="RIGHT">
<button><i class="fa-solid fa-rotate-right"></i></button>
</form>
</div> </div>
</div> </div>
</div> </div>
+90
View File
@@ -12,16 +12,19 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from io import BytesIO
from typing import Callable from typing import Callable
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.base import ContentFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from PIL import Image
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
@@ -297,6 +300,93 @@ class TestAlbumEdit:
assert localdate(album.date) == localdate() assert localdate(album.date) == localdate()
@pytest.mark.django_db
class TestPictureRotation:
@pytest.fixture
def picture(self) -> Picture:
# Creating a fake image from scratch is painful
# One of the base image in the test set is good enough
return Picture.objects.get(name="sli.jpg")
def load_image(self, file: ContentFile) -> Image.Image:
file.seek(0)
im = Image.open(BytesIO(file.read()))
file.seek(0)
return im
@pytest.mark.parametrize(
"user",
[
None,
lambda: baker.make(User),
subscriber_user.make,
old_subscriber_user.make,
],
)
def test_permission_denied(
self,
client: Client,
picture: Picture,
user: Callable[[], User] | None,
):
if user:
client.force_login(user())
payload = {
"picture": picture.pk,
"direction": "LEFT",
}
url = reverse("sas:picture_rotate")
response = client.post(url, payload)
if user:
assert response.status_code == 403
else:
assertRedirects(
response,
reverse(
"core:login",
query={
"next": url,
},
),
)
@pytest.mark.parametrize(
"user",
[
lambda: baker.make(User, is_superuser=True),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
],
)
def test_rotation(
self,
client: Client,
picture: Picture,
user: Callable[[], User],
):
client.force_login(user())
payload = {
"picture": picture.pk,
"direction": "LEFT",
}
response = client.post(reverse("sas:picture_rotate"), payload)
assertRedirects(
response, reverse("sas:picture", kwargs={"picture_id": picture.pk})
)
payload = {
"picture": picture.pk,
"direction": "RIGHT",
}
response = client.post(reverse("sas:picture_rotate"), payload)
assertRedirects(
response, reverse("sas:picture", kwargs={"picture_id": picture.pk})
)
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
+2
View File
@@ -22,6 +22,7 @@ from sas.views import (
ModerationView, ModerationView,
PictureAskRemovalView, PictureAskRemovalView,
PictureEditView, PictureEditView,
PictureRotateView,
PictureView, PictureView,
SASMainView, SASMainView,
UserPicturesView, UserPicturesView,
@@ -52,6 +53,7 @@ urlpatterns = [
send_compressed, send_compressed,
name="download_compressed", name="download_compressed",
), ),
path("picture/rotate", PictureRotateView.as_view(), name="picture_rotate"),
path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"), path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"),
path( path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures" "user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
+30 -8
View File
@@ -15,12 +15,14 @@
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
@@ -35,6 +37,7 @@ from sas.forms import (
AlbumEditForm, AlbumEditForm,
PictureEditForm, PictureEditForm,
PictureModerationRequestForm, PictureModerationRequestForm,
PictureRotationForm,
PictureUploadForm, PictureUploadForm,
) )
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
@@ -96,20 +99,39 @@ class PictureView(CanViewMixin, DetailView):
pk_url_kwarg = "picture_id" pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja" template_name = "sas/picture.jinja"
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "rotate_right" in request.GET:
self.object.rotate(270)
if "rotate_left" in request.GET:
self.object.rotate(90)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
"album": Album.objects.get(children=self.object) "album": Album.objects.get(children=self.object)
} }
class PictureRotateView(PermissionRequiredMixin, FormView):
form_class = PictureRotationForm
template_name = "core/edit.jinja"
permission_required = "sas.moderate_sasfile"
def form_valid(self, form: PictureRotationForm):
angles = {"RIGHT": 270, "LEFT": 90}
cleaned = form.clean()
cleaned["picture"].rotate(angles[cleaned["direction"]])
self._success_url = reverse(
"sas:picture",
kwargs={
"picture_id": cleaned["picture"].pk,
},
)
messages.warning(
self.request,
_(
"Newly rotated image might not be immediately displayed due to your web browser's cache"
),
)
return super().form_valid(form)
def get_success_url(self):
return self._success_url
def send_album(request, album_id): def send_album(request, album_id):
return send_file(request, album_id, Album) return send_file(request, album_id, Album)