mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-10 18:18:50 +00:00
Compare commits
2 Commits
dev
...
feature-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e197f259c7 | ||
|
|
f3ba77c25c |
@@ -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"<script>alert('xss')</script>",
|
||||
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:
|
||||
|
||||
@@ -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,43 @@ 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":
|
||||
"""
|
||||
Validates and sanitizes the uploaded app logo image. Model field already restricts to
|
||||
jpg/png/gif/svg see src/paperless/models.py#L200
|
||||
"""
|
||||
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:
|
||||
|
||||
@@ -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 exceeds the allowed size.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user