Skip to content

Commit 7d17222

Browse files
committed
fix: improve error handling in image validation
- use contenxt to work with the file - use targettet exceptions instead of wide Exception
1 parent 6d29012 commit 7d17222

2 files changed

Lines changed: 130 additions & 13 deletions

File tree

weblate_web/models.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919

2020
from __future__ import annotations
2121

22-
import sys
2322
from datetime import datetime, timedelta
2423
from io import BytesIO
2524
from typing import TYPE_CHECKING, Any
@@ -72,6 +71,12 @@
7271
from weblate_web.invoices.models import InvoiceKind
7372

7473
ALLOWED_IMAGES = {"image/jpeg", "image/png"}
74+
PILLOW_VALIDATION_ERRORS = (
75+
OSError,
76+
SyntaxError,
77+
ValueError,
78+
PILImage.DecompressionBombError,
79+
)
7580

7681
REWARDS = (
7782
(0, gettext_lazy("No reward")),
@@ -156,21 +161,24 @@ def validate_bitmap(value) -> None:
156161
# load() could spot a truncated JPEG, but it loads the entire
157162
# image in memory, which is a DoS vector. See #3848 and #18520.
158163
image = PILImage.open(content)
159-
# verify() must be called immediately after the constructor.
160-
image.verify()
164+
except PILLOW_VALIDATION_ERRORS as error:
165+
# Pillow doesn't recognize it as a valid image.
166+
raise ValidationError(_("Invalid image!"), code="invalid_image") from error
167+
168+
with image:
169+
try:
170+
# verify() must be called immediately after the constructor.
171+
image.verify()
172+
except PILLOW_VALIDATION_ERRORS as error:
173+
# Pillow doesn't recognize it as a valid image.
174+
raise ValidationError(_("Invalid image!"), code="invalid_image") from error
161175

162176
# Pillow doesn't detect the MIME type of all formats. In those
163177
# cases, content_type will be None.
164178
value.file.content_type = PILImage.MIME.get(
165179
image.format # type: ignore[arg-type]
166180
)
167-
except Exception as error:
168-
# Pillow doesn't recognize it as an image.
169-
raise ValidationError(_("Invalid image!"), code="invalid_image").with_traceback(
170-
sys.exc_info()[2]
171-
) from error
172181

173-
try:
174182
if hasattr(value.file, "seek") and callable(value.file.seek):
175183
value.file.seek(0)
176184

@@ -186,9 +194,6 @@ def validate_bitmap(value) -> None:
186194
_("Please upload an image with a resolution of 570 x 260 pixels.")
187195
)
188196

189-
finally:
190-
image.close()
191-
192197

193198
class MySQLSearchLookup(models.Lookup): # pylint: disable=abstract-method
194199
lookup_name = "search"

weblate_web/tests.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
from datetime import date, timedelta
77
from decimal import Decimal
88
from importlib import import_module
9-
from io import StringIO
9+
from io import BytesIO, StringIO
1010
from pathlib import Path
1111
from typing import TYPE_CHECKING, Any, Literal, cast
1212
from unittest.mock import PropertyMock, patch
1313
from uuid import uuid4
1414
from xml.etree import ElementTree # noqa: S405
15+
from zlib import crc32
1516

1617
import responses
1718
from dateutil.relativedelta import relativedelta
@@ -20,6 +21,7 @@
2021
from django.core import mail
2122
from django.core.cache import cache
2223
from django.core.exceptions import ValidationError
24+
from django.core.files.uploadedfile import SimpleUploadedFile
2325
from django.core.management import call_command
2426
from django.core.signing import dumps
2527
from django.db import connection
@@ -30,6 +32,7 @@
3032
from django.urls import reverse
3133
from django.utils import timezone
3234
from django.utils.translation import override
35+
from PIL import Image as PILImage
3336
from requests.exceptions import HTTPError
3437
from wlc import WeblateException
3538

@@ -54,6 +57,7 @@
5457
get_donation_package,
5558
get_donation_reward_package_name,
5659
sync_packages,
60+
validate_bitmap,
5761
)
5862
from .payments.validators import VAT_VALIDITY_DAYS
5963
from .remote import (
@@ -1015,6 +1019,114 @@ def test_downloadlink(self) -> None:
10151019
)
10161020

10171021

1022+
class BitmapValidationTest(SimpleTestCase):
1023+
"""Bitmap validation testing."""
1024+
1025+
@staticmethod
1026+
def get_image_content(
1027+
image_format: str, size: tuple[int, int] = (570, 260)
1028+
) -> bytes:
1029+
content = BytesIO()
1030+
PILImage.new("RGB", size).save(content, image_format)
1031+
return content.getvalue()
1032+
1033+
@classmethod
1034+
def get_uploaded_image(
1035+
cls, image_format: str, size: tuple[int, int] = (570, 260)
1036+
) -> SimpleUploadedFile:
1037+
return SimpleUploadedFile(
1038+
f"image.{image_format.lower()}", cls.get_image_content(image_format, size)
1039+
)
1040+
1041+
@staticmethod
1042+
def corrupt_png_chunk_crc(content: bytes, chunk_type: bytes) -> bytes:
1043+
data = bytearray(content)
1044+
position = 8
1045+
while position < len(data):
1046+
chunk_length = int.from_bytes(data[position : position + 4], "big")
1047+
crc_position = position + 8 + chunk_length
1048+
if data[position + 4 : position + 8] == chunk_type:
1049+
data[crc_position] ^= 1
1050+
return bytes(data)
1051+
position = crc_position + 4
1052+
raise ValueError(f"Missing PNG chunk: {chunk_type!r}")
1053+
1054+
@staticmethod
1055+
def resize_png_header(content: bytes, size: tuple[int, int]) -> bytes:
1056+
data = bytearray(content)
1057+
data[16:20] = size[0].to_bytes(4, "big")
1058+
data[20:24] = size[1].to_bytes(4, "big")
1059+
data[29:33] = crc32(data[12:29]).to_bytes(4, "big")
1060+
return bytes(data)
1061+
1062+
def test_valid_png(self) -> None:
1063+
upload = self.get_uploaded_image("PNG")
1064+
1065+
validate_bitmap(upload)
1066+
1067+
upload_file = upload.file
1068+
if upload_file is None:
1069+
self.fail("Uploaded file was closed")
1070+
self.assertEqual(cast("Any", upload_file).content_type, "image/png")
1071+
self.assertEqual(upload_file.tell(), 0)
1072+
1073+
def test_invalid_image(self) -> None:
1074+
upload = SimpleUploadedFile("image.png", b"invalid image")
1075+
1076+
with self.assertRaises(ValidationError) as error:
1077+
validate_bitmap(upload)
1078+
1079+
self.assertEqual(error.exception.code, "invalid_image")
1080+
self.assertEqual(error.exception.messages, ["Invalid image!"])
1081+
1082+
def test_corrupt_identified_image(self) -> None:
1083+
upload = SimpleUploadedFile(
1084+
"image.png",
1085+
self.corrupt_png_chunk_crc(self.get_image_content("PNG"), b"IDAT"),
1086+
)
1087+
1088+
with self.assertRaises(ValidationError) as error:
1089+
validate_bitmap(upload)
1090+
1091+
self.assertEqual(error.exception.code, "invalid_image")
1092+
self.assertEqual(error.exception.messages, ["Invalid image!"])
1093+
1094+
def test_decompression_bomb_image(self) -> None:
1095+
upload = SimpleUploadedFile(
1096+
"image.png",
1097+
self.resize_png_header(
1098+
self.get_image_content("PNG", (1, 1)), (20_000, 20_000)
1099+
),
1100+
)
1101+
1102+
with self.assertRaises(ValidationError) as error:
1103+
validate_bitmap(upload)
1104+
1105+
self.assertEqual(error.exception.code, "invalid_image")
1106+
self.assertEqual(error.exception.messages, ["Invalid image!"])
1107+
1108+
def test_unsupported_image_type(self) -> None:
1109+
upload = self.get_uploaded_image("GIF")
1110+
1111+
with self.assertRaises(ValidationError) as error:
1112+
validate_bitmap(upload)
1113+
1114+
self.assertEqual(
1115+
error.exception.messages, ["Unsupported image type: image/gif"]
1116+
)
1117+
1118+
def test_invalid_image_dimensions(self) -> None:
1119+
upload = self.get_uploaded_image("PNG", (570, 261))
1120+
1121+
with self.assertRaises(ValidationError) as error:
1122+
validate_bitmap(upload)
1123+
1124+
self.assertEqual(
1125+
error.exception.messages,
1126+
["Please upload an image with a resolution of 570 x 260 pixels."],
1127+
)
1128+
1129+
10181130
def create_payment(
10191131
*,
10201132
recurring: Literal["y", ""] = "y",

0 commit comments

Comments
 (0)