|
6 | 6 | from datetime import date, timedelta |
7 | 7 | from decimal import Decimal |
8 | 8 | from importlib import import_module |
9 | | -from io import StringIO |
| 9 | +from io import BytesIO, StringIO |
10 | 10 | from pathlib import Path |
11 | 11 | from typing import TYPE_CHECKING, Any, Literal, cast |
12 | 12 | from unittest.mock import PropertyMock, patch |
13 | 13 | from uuid import uuid4 |
14 | 14 | from xml.etree import ElementTree # noqa: S405 |
| 15 | +from zlib import crc32 |
15 | 16 |
|
16 | 17 | import responses |
17 | 18 | from dateutil.relativedelta import relativedelta |
|
20 | 21 | from django.core import mail |
21 | 22 | from django.core.cache import cache |
22 | 23 | from django.core.exceptions import ValidationError |
| 24 | +from django.core.files.uploadedfile import SimpleUploadedFile |
23 | 25 | from django.core.management import call_command |
24 | 26 | from django.core.signing import dumps |
25 | 27 | from django.db import connection |
|
30 | 32 | from django.urls import reverse |
31 | 33 | from django.utils import timezone |
32 | 34 | from django.utils.translation import override |
| 35 | +from PIL import Image as PILImage |
33 | 36 | from requests.exceptions import HTTPError |
34 | 37 | from wlc import WeblateException |
35 | 38 |
|
|
54 | 57 | get_donation_package, |
55 | 58 | get_donation_reward_package_name, |
56 | 59 | sync_packages, |
| 60 | + validate_bitmap, |
57 | 61 | ) |
58 | 62 | from .payments.validators import VAT_VALIDITY_DAYS |
59 | 63 | from .remote import ( |
@@ -1015,6 +1019,114 @@ def test_downloadlink(self) -> None: |
1015 | 1019 | ) |
1016 | 1020 |
|
1017 | 1021 |
|
| 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 | + |
1018 | 1130 | def create_payment( |
1019 | 1131 | *, |
1020 | 1132 | recurring: Literal["y", ""] = "y", |
|
0 commit comments