diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index e7b599cbb..1ca81390f 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -6,6 +6,8 @@ from unittest.mock import patch from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from django.test import override_settings +from PIL import Image +from PIL.PngImagePlugin import PngInfo from rest_framework import status from rest_framework.test import APITestCase @@ -201,6 +203,125 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): ) self.assertFalse(Path(old_logo.path).exists()) + def test_api_strips_exif_data_from_uploaded_logo(self) -> None: + """ + GIVEN: + - A JPEG logo upload containing EXIF metadata + WHEN: + - Uploaded via PATCH to app config + THEN: + - Stored logo image has EXIF metadata removed + """ + image = Image.new("RGB", (12, 12), "blue") + exif = Image.Exif() + exif[315] = "Paperless Test Author" + + logo = BytesIO() + image.save(logo, format="JPEG", exif=exif) + logo.seek(0) + + response = self.client.patch( + f"{self.ENDPOINT}1/", + { + "app_logo": SimpleUploadedFile( + name="logo-with-exif.jpg", + content=logo.getvalue(), + content_type="image/jpeg", + ), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + config = ApplicationConfiguration.objects.first() + with Image.open(config.app_logo.path) as stored_logo: + stored_exif = stored_logo.getexif() + + self.assertEqual(len(stored_exif), 0) + + def test_api_strips_png_metadata_from_uploaded_logo(self) -> None: + """ + GIVEN: + - A PNG logo upload containing text metadata + WHEN: + - Uploaded via PATCH to app config + THEN: + - Stored logo image has metadata removed + """ + image = Image.new("RGB", (12, 12), "green") + pnginfo = PngInfo() + pnginfo.add_text("Author", "Paperless Test Author") + + logo = BytesIO() + image.save(logo, format="PNG", pnginfo=pnginfo) + logo.seek(0) + + response = self.client.patch( + f"{self.ENDPOINT}1/", + { + "app_logo": SimpleUploadedFile( + name="logo-with-metadata.png", + content=logo.getvalue(), + content_type="image/png", + ), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + config = ApplicationConfiguration.objects.first() + with Image.open(config.app_logo.path) as stored_logo: + stored_text = stored_logo.text + + self.assertEqual(stored_text, {}) + + def test_api_accepts_valid_gif_logo(self) -> None: + """ + GIVEN: + - A valid GIF logo upload + WHEN: + - Uploaded via PATCH to app config + THEN: + - Upload succeeds + """ + image = Image.new("RGB", (12, 12), "red") + + logo = BytesIO() + image.save(logo, format="GIF", comment=b"Paperless Test Comment") + logo.seek(0) + + response = self.client.patch( + f"{self.ENDPOINT}1/", + { + "app_logo": SimpleUploadedFile( + name="logo.gif", + content=logo.getvalue(), + content_type="image/gif", + ), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_api_rejects_invalid_raster_logo(self) -> None: + """ + GIVEN: + - A file named as a JPEG but containing non-image payload data + WHEN: + - Uploaded via PATCH to app config + THEN: + - Upload is rejected with 400 + """ + response = self.client.patch( + f"{self.ENDPOINT}1/", + { + "app_logo": SimpleUploadedFile( + name="not-an-image.jpg", + content=b"", + content_type="image/jpeg", + ), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("invalid logo image", str(response.data).lower()) + def test_api_rejects_malicious_svg_logo(self) -> None: """ GIVEN: diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 4939bc1ba..af9a3c511 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -1,4 +1,5 @@ import logging +from io import BytesIO import magic from allauth.mfa.adapter import get_adapter as get_mfa_adapter @@ -11,13 +12,16 @@ from django.contrib.auth.models import Group from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.contrib.auth.password_validation import validate_password +from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.files.uploadedfile import UploadedFile +from PIL import Image from rest_framework import serializers from rest_framework.authtoken.serializers import AuthTokenSerializer from paperless.models import ApplicationConfiguration from paperless.network import validate_outbound_http_url from paperless.validators import reject_dangerous_svg +from paperless.validators import validate_raster_image from paperless_mail.serialisers import ObfuscatedPasswordField logger = logging.getLogger("paperless.settings") @@ -233,9 +237,39 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer): instance.app_logo.delete() return super().update(instance, validated_data) + def _sanitize_raster_image(self, file: UploadedFile) -> UploadedFile: + try: + data = BytesIO() + image = Image.open(file) + image.save(data, format=image.format) + data.seek(0) + + return InMemoryUploadedFile( + file=data, + field_name=file.field_name, + name=file.name, + content_type=file.content_type, + size=data.getbuffer().nbytes, + charset=getattr(file, "charset", None), + ) + finally: + image.close() + def validate_app_logo(self, file: UploadedFile): - if file and magic.from_buffer(file.read(2048), mime=True) == "image/svg+xml": + if not file: + return file + + mime_type = magic.from_buffer(file.read(2048), mime=True) + + if mime_type == "image/svg+xml": reject_dangerous_svg(file) + return file + + validate_raster_image(file) + + if mime_type in {"image/jpeg", "image/png"}: + file = self._sanitize_raster_image(file) + return file def validate_llm_endpoint(self, value: str | None) -> str | None: diff --git a/src/paperless/validators.py b/src/paperless/validators.py index bb741df41..5de9f972d 100644 --- a/src/paperless/validators.py +++ b/src/paperless/validators.py @@ -1,6 +1,10 @@ +from io import BytesIO + +from django.conf import settings from django.core.exceptions import ValidationError from django.core.files.uploadedfile import UploadedFile from lxml import etree +from PIL import Image ALLOWED_SVG_TAGS: set[str] = { # Basic shapes @@ -254,3 +258,30 @@ def reject_dangerous_svg(file: UploadedFile) -> None: raise ValidationError( f"URI scheme not allowed in {attr_name}: must be #anchor, relative path, or data:image/*", ) + + +def validate_raster_image(file: UploadedFile) -> None: + """ + Validates that the uploaded file is a valid raster image (JPEG, PNG, etc.) + and does not exceed maximum pixel limits. + Raises ValidationError if the image is invalid or potentially dangerous. + """ + + file.seek(0) + image_data = file.read() + try: + with Image.open(BytesIO(image_data)) as image: + image.verify() + + if ( + settings.MAX_IMAGE_PIXELS is not None + and settings.MAX_IMAGE_PIXELS > 0 + and image.width * image.height > settings.MAX_IMAGE_PIXELS + ): + raise ValidationError( + "Uploaded logo exceeds the maximum allowed image size.", + ) + if image.format is None: + raise ValidationError("Invalid logo image.") + except (OSError, Image.DecompressionBombError) as e: + raise ValidationError("Invalid logo image.") from e