uploadshield 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. uploadshield/__init__.py +49 -0
  2. uploadshield/__main__.py +17 -0
  3. uploadshield/checks/__init__.py +65 -0
  4. uploadshield/checks/archive.py +316 -0
  5. uploadshield/checks/basic.py +476 -0
  6. uploadshield/checks/image.py +107 -0
  7. uploadshield/checks/magika.py +129 -0
  8. uploadshield/checks/markup.py +181 -0
  9. uploadshield/checks/office.py +174 -0
  10. uploadshield/checks/pdf.py +277 -0
  11. uploadshield/checks/scanners.py +205 -0
  12. uploadshield/checks/xml.py +123 -0
  13. uploadshield/cli/__init__.py +276 -0
  14. uploadshield/cli/_commands.py +401 -0
  15. uploadshield/cli/_json.py +80 -0
  16. uploadshield/cli/_render.py +319 -0
  17. uploadshield/cli/_scan.py +106 -0
  18. uploadshield/cli/_style.py +95 -0
  19. uploadshield/cli/_verdicts.py +363 -0
  20. uploadshield/cli/_walk.py +159 -0
  21. uploadshield/core/__init__.py +4 -0
  22. uploadshield/core/_deconstruct.py +39 -0
  23. uploadshield/core/check.py +79 -0
  24. uploadshield/core/containers.py +131 -0
  25. uploadshield/core/result.py +63 -0
  26. uploadshield/core/sniffing.py +432 -0
  27. uploadshield/core/types.py +319 -0
  28. uploadshield/core/upload.py +192 -0
  29. uploadshield/core/violations.py +79 -0
  30. uploadshield/django/__init__.py +36 -0
  31. uploadshield/django/apps.py +13 -0
  32. uploadshield/django/checks.py +148 -0
  33. uploadshield/django/cli.py +36 -0
  34. uploadshield/django/conf.py +189 -0
  35. uploadshield/django/decorators.py +204 -0
  36. uploadshield/django/exceptions.py +39 -0
  37. uploadshield/django/forms.py +40 -0
  38. uploadshield/django/handlers.py +322 -0
  39. uploadshield/django/responses.py +92 -0
  40. uploadshield/django/service.py +473 -0
  41. uploadshield/django/signals.py +16 -0
  42. uploadshield/django/storage.py +204 -0
  43. uploadshield/django/templates/uploadshield/rejected.html +15 -0
  44. uploadshield/django/validators.py +48 -0
  45. uploadshield/django/views.py +40 -0
  46. uploadshield/engine.py +230 -0
  47. uploadshield/fastapi/__init__.py +23 -0
  48. uploadshield/fastapi/shield.py +299 -0
  49. uploadshield/flask/__init__.py +42 -0
  50. uploadshield/flask/core.py +359 -0
  51. uploadshield/flask/decorators.py +69 -0
  52. uploadshield/flask/wtforms.py +58 -0
  53. uploadshield/policy.py +137 -0
  54. uploadshield/presets.py +65 -0
  55. uploadshield/py.typed +0 -0
  56. uploadshield/sanitizers.py +132 -0
  57. uploadshield/web.py +218 -0
  58. uploadshield-2.0.0.dist-info/METADATA +239 -0
  59. uploadshield-2.0.0.dist-info/RECORD +62 -0
  60. uploadshield-2.0.0.dist-info/WHEEL +4 -0
  61. uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
  62. uploadshield-2.0.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,476 @@
1
+ """Cheap checks on size, name and type that every policy runs first."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING
9
+ from urllib.parse import unquote
10
+
11
+ from uploadshield.core.check import Check
12
+ from uploadshield.core.types import (
13
+ FileType,
14
+ all_types,
15
+ format_size,
16
+ get_type,
17
+ normalize_mime,
18
+ resolve_mime,
19
+ )
20
+ from uploadshield.core.violations import Code, Violation
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterable, Iterator
24
+
25
+ from uploadshield.core.upload import InspectedFile
26
+
27
+ __all__ = [
28
+ "DANGEROUS_EXTENSIONS",
29
+ "DANGEROUS_NAMES",
30
+ "WINDOWS_RESERVED_NAMES",
31
+ "AllowedTypes",
32
+ "DeclaredTypeMatches",
33
+ "ExtensionMatchesType",
34
+ "FileSize",
35
+ "FilenameCheck",
36
+ "extension_segments",
37
+ "name_variants",
38
+ ]
39
+
40
+ _PUBLIC = "uploadshield.checks"
41
+
42
+ #: Extensions that web servers may execute or treat as configuration, and file types
43
+ #: Windows runs when a user opens a download. They are refused *anywhere* in a name,
44
+ #: defeating ``shell.php.jpg`` and ``shell.asp;.jpg``. A defense in depth only: the
45
+ #: allow-list on the *content* is what decides (OWASP: blocklists alone are weak).
46
+ DANGEROUS_EXTENSIONS = frozenset(
47
+ {
48
+ "php",
49
+ "php2",
50
+ "php3",
51
+ "php4",
52
+ "php5",
53
+ "php6",
54
+ "php7",
55
+ "php8",
56
+ "phpt",
57
+ "pht",
58
+ "phtm",
59
+ "phtml",
60
+ "phar",
61
+ "phps",
62
+ "pgif",
63
+ "inc",
64
+ "hphp",
65
+ "ctp",
66
+ "shtml",
67
+ "shtm",
68
+ "stm",
69
+ "asp",
70
+ "aspx",
71
+ "aspq",
72
+ "ascx",
73
+ "ashx",
74
+ "asmx",
75
+ "asax",
76
+ "asa",
77
+ "cer",
78
+ "cshtml",
79
+ "cshtm",
80
+ "vbhtml",
81
+ "vbhtm",
82
+ "axd",
83
+ "rem",
84
+ "soap",
85
+ "svc",
86
+ "jsp",
87
+ "jspx",
88
+ "jspf",
89
+ "jsw",
90
+ "jsv",
91
+ "wss",
92
+ "war",
93
+ "cfm",
94
+ "cfml",
95
+ "cfc",
96
+ "dbm",
97
+ "cgi",
98
+ "pl",
99
+ "pm",
100
+ "py",
101
+ "pyc",
102
+ "pyw",
103
+ "rb",
104
+ "sh",
105
+ "bash",
106
+ "ksh",
107
+ "zsh",
108
+ "bat",
109
+ "cmd",
110
+ "com",
111
+ "exe",
112
+ "dll",
113
+ "msi",
114
+ "msp",
115
+ "scr",
116
+ "pif",
117
+ "cpl",
118
+ "vbs",
119
+ "vbe",
120
+ "js",
121
+ "jse",
122
+ "wsf",
123
+ "wsh",
124
+ "wsc",
125
+ "ws",
126
+ "sct",
127
+ "hta",
128
+ "ps1",
129
+ "psm1",
130
+ "psd1",
131
+ "ps1xml",
132
+ "lnk",
133
+ "url",
134
+ "scf",
135
+ "reg",
136
+ "inf",
137
+ "msc",
138
+ "chm",
139
+ "hlp",
140
+ "jar",
141
+ "jnlp",
142
+ "swf", # Flash and Silverlight: cross-site content hijacking
143
+ "xap",
144
+ "application",
145
+ "appref-ms",
146
+ "gadget",
147
+ "settingcontent-ms",
148
+ "iqy",
149
+ "slk",
150
+ "xll",
151
+ "htaccess",
152
+ "htpasswd",
153
+ }
154
+ )
155
+
156
+ #: Whole names that act as server or browser configuration when dropped into a directory:
157
+ #: web server config, and the Flash/Silverlight cross-domain policies browsers obey.
158
+ DANGEROUS_NAMES = frozenset(
159
+ {
160
+ ".htaccess",
161
+ ".htpasswd",
162
+ "web.config",
163
+ ".user.ini",
164
+ "php.ini",
165
+ "crossdomain.xml",
166
+ "clientaccesspolicy.xml",
167
+ }
168
+ )
169
+
170
+ WINDOWS_RESERVED_NAMES = _WINDOWS_RESERVED = frozenset(
171
+ {"con", "prn", "aux", "nul", "conin$", "conout$"}
172
+ | {f"com{i}" for i in range(10)}
173
+ | {f"lpt{i}" for i in range(10)}
174
+ )
175
+ #: Characters Windows forbids in names (besides separators): they also break shell
176
+ #: quoting (``|``, ``*``, ``?``), header quoting (``"``) and unescaped HTML (``<>``).
177
+ _WINDOWS_FORBIDDEN = '<>"|?*'
178
+ #: A Windows 8.3 short-name alias such as ``WEBCON~1.CON`` (OWASP: overwrite checks
179
+ #: that only look at long names can be bypassed through it).
180
+ _SHORT_NAME = re.compile(r"^[^~.\s]{1,6}~[0-9]{1,6}(?:\.[^.]{1,3})?$")
181
+ _BIDI_CONTROLS = frozenset(
182
+ "\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069"
183
+ )
184
+ _SEGMENT_TERMINATORS = re.compile(r"[:;\s]")
185
+
186
+
187
+ def _is_forbidden_char(char: str) -> bool:
188
+ # Control characters (C0, DEL, C1), bidi overrides, path separators including ":"
189
+ # (Windows drives and NTFS streams: os.path.join("D:\\up", "C:x.png") is "C:x.png"),
190
+ # and the other characters Windows forbids. Other format characters are legitimate:
191
+ # emoji ZWJ sequences, and the U+202F narrow no-break space macOS puts into
192
+ # screenshot names. Lone surrogates ("Cs") are bytes that were not valid UTF-8 in a
193
+ # name read from disk: no filesystem can store them as text.
194
+ return (
195
+ char in _BIDI_CONTROLS
196
+ or char in "/\\:"
197
+ or char in _WINDOWS_FORBIDDEN
198
+ or unicodedata.category(char) in ("Cc", "Cs")
199
+ )
200
+
201
+
202
+ def name_variants(name: str) -> list[tuple[str, str]]:
203
+ """``(variant, how)`` pairs: the name itself and how later layers may read it.
204
+
205
+ OWASP: validate *after* decoding. Names are percent-decoded repeatedly (double
206
+ encoding: ``%2500`` -> ``%00`` -> NUL) and NFKC-normalised, which is what Windows'
207
+ "best fit" conversion and many filesystems do to fullwidth forms (U+FF50 U+FF48
208
+ U+FF50 -> ``php``, U+FF0F -> ``/``) and superscripts (``COM\u00b9`` -> ``COM1``).
209
+ """
210
+ variants = [(name, "raw")]
211
+ current = name
212
+ for _ in range(3):
213
+ decoded = unquote(current)
214
+ if decoded == current:
215
+ break
216
+ variants.append((decoded, "percent-decoded"))
217
+ current = decoded
218
+ for text, _how in list(variants):
219
+ normalized = unicodedata.normalize("NFKC", text)
220
+ if all(normalized != seen for seen, _ in variants):
221
+ variants.append((normalized, "normalized"))
222
+ return variants
223
+
224
+
225
+ #: Content that executes scripts in browsers; wildcards never match these.
226
+ _SCRIPTABLE = frozenset({"text/html", "image/svg+xml", "application/xml"})
227
+
228
+
229
+ @dataclass(frozen=True, slots=True)
230
+ class FileSize(Check):
231
+ """Refuse empty files and files larger than ``max_bytes``."""
232
+
233
+ _deconstruct_module = _PUBLIC
234
+ name = "size"
235
+ stage = "size"
236
+
237
+ max_bytes: int | None = None
238
+ allow_empty: bool = False
239
+
240
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
241
+ if file.size == 0 and not self.allow_empty:
242
+ yield self.violation(Code.FILE_EMPTY, "The file is empty.")
243
+ if self.max_bytes is not None and file.size > self.max_bytes:
244
+ yield self.violation(
245
+ Code.FILE_TOO_LARGE,
246
+ f"The file is too large (maximum {format_size(self.max_bytes)}).",
247
+ size=file.size,
248
+ limit=self.max_bytes,
249
+ )
250
+
251
+
252
+ @dataclass(frozen=True, slots=True)
253
+ class FilenameCheck(Check):
254
+ """Refuse names that can attack storage backends, web servers or users.
255
+
256
+ Follows the OWASP File Upload Cheat Sheet: the name is judged as given *and* as
257
+ decoding layers may later read it (:func:`name_variants`). It refuses control and
258
+ bidi-override characters, path and drive separators and the other characters
259
+ Windows forbids, hidden files and server configuration names, Windows device and
260
+ 8.3 short names, leading hyphens or spaces (argument injection), consecutive dots,
261
+ trailing dots/spaces, over-long names and executable extensions in any position
262
+ (double extensions).
263
+ """
264
+
265
+ _deconstruct_module = _PUBLIC
266
+ name = "filename"
267
+ stage = "name"
268
+
269
+ max_length: int = 255
270
+ allow_hidden: bool = False
271
+ require_extension: bool = True
272
+ forbidden_extensions: frozenset[str] = DANGEROUS_EXTENSIONS
273
+ forbidden_names: frozenset[str] = DANGEROUS_NAMES
274
+ allow_leading_hyphen: bool = False
275
+ allow_consecutive_dots: bool = False
276
+ allow_short_names: bool = False
277
+
278
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
279
+ name = file.name
280
+ if not name.strip():
281
+ yield self.violation(Code.FILENAME_INVALID, "The file has no name.")
282
+ return
283
+ reported: set[tuple[str, str]] = set()
284
+ for variant, how in name_variants(name):
285
+ for found in self._name_rules(variant, how):
286
+ key = (str(found.code), found.message)
287
+ if key in reported:
288
+ continue
289
+ reported.add(key)
290
+ details = (
291
+ dict(found.details) if how == "raw" else {**found.details, "read_as": variant}
292
+ )
293
+ yield self.violation(found.code, found.message, **details)
294
+ if len(name.encode("utf-8", "surrogatepass")) > self.max_length:
295
+ yield self.violation(
296
+ Code.FILENAME_TOO_LONG,
297
+ f"The file name is too long (maximum {self.max_length} bytes).",
298
+ limit=self.max_length,
299
+ )
300
+ if self.require_extension and not file.extensions:
301
+ yield self.violation(Code.FILENAME_MISSING_EXTENSION, "The file name has no extension.")
302
+
303
+ def _name_rules(self, name: str, how: str) -> Iterator[Violation]:
304
+ if any(_is_forbidden_char(c) for c in name):
305
+ yield self.violation(
306
+ Code.FILENAME_INVALID, "The file name contains invalid characters."
307
+ )
308
+ if name != name.rstrip(". "):
309
+ yield self.violation(
310
+ Code.FILENAME_INVALID, "The file name must not end with a dot or space."
311
+ )
312
+ if name[:1].isspace() or (name.startswith("-") and not self.allow_leading_hyphen):
313
+ yield self.violation(
314
+ Code.FILENAME_INVALID, "The file name must not start with a hyphen or space."
315
+ )
316
+ # NFKC turns the ellipsis "…" into "...": harmless on its own, so only the
317
+ # name as sent (or percent-decoded) counts here.
318
+ if not self.allow_consecutive_dots and how != "normalized" and ".." in name.strip("."):
319
+ yield self.violation(
320
+ Code.FILENAME_INVALID, "The file name must not contain consecutive dots."
321
+ )
322
+ lowered = name.lower()
323
+ if lowered in self.forbidden_names:
324
+ yield self.violation(Code.FILENAME_RESERVED, "This file name is not allowed.")
325
+ elif name.startswith(".") and not self.allow_hidden:
326
+ yield self.violation(Code.FILENAME_HIDDEN, "Hidden files are not allowed.")
327
+ if lowered in {".", ".."} or lowered.split(".", 1)[0].strip() in _WINDOWS_RESERVED:
328
+ yield self.violation(Code.FILENAME_RESERVED, "This file name is reserved.")
329
+ if not self.allow_short_names and _SHORT_NAME.match(name):
330
+ yield self.violation(
331
+ Code.FILENAME_RESERVED, "The file name looks like a Windows short (8.3) name."
332
+ )
333
+ if dangerous := sorted(set(self._segments(lowered)) & self.forbidden_extensions):
334
+ yield self.violation(
335
+ Code.FILENAME_DANGEROUS_EXTENSION,
336
+ "The file name contains a forbidden extension.",
337
+ extensions=dangerous,
338
+ )
339
+
340
+ @staticmethod
341
+ def _segments(lowered_name: str) -> Iterator[str]:
342
+ return extension_segments(lowered_name)
343
+
344
+
345
+ def extension_segments(lowered_name: str) -> Iterator[str]:
346
+ """Every dot-separated segment after the first, as a server would read it.
347
+
348
+ ``"a.asp;.jpg"`` yields ``"asp"`` (IIS ``;``), ``"a.asp::$data"`` yields ``"asp"``
349
+ (NTFS streams), and ``"a.php .jpg"`` yields ``"php"``.
350
+ """
351
+ for segment in lowered_name.lstrip(".").split(".")[1:]:
352
+ yield _SEGMENT_TERMINATORS.split(segment, maxsplit=1)[0]
353
+
354
+
355
+ def normalize_allowed_types(entries: Iterable[str | FileType]) -> frozenset[str]:
356
+ """Canonicalise MIME types/aliases and validate wildcards; unknown types raise."""
357
+ normalized: set[str] = set()
358
+ for entry in entries:
359
+ if isinstance(entry, FileType):
360
+ normalized.add(entry.mime)
361
+ continue
362
+ value = normalize_mime(entry)
363
+ if value.endswith("/*"):
364
+ category = value[:-2]
365
+ if not any(t.category == category for t in all_types()):
366
+ raise ValueError(f"Unknown media category in wildcard {entry!r}")
367
+ normalized.add(value)
368
+ else:
369
+ normalized.add(resolve_mime(value).mime)
370
+ return frozenset(normalized)
371
+
372
+
373
+ @dataclass(frozen=True, slots=True)
374
+ class AllowedTypes(Check):
375
+ """Allow only files whose *content* is one of ``types``.
376
+
377
+ Entries are MIME types (aliases are resolved, unknown types raise) or wildcards
378
+ such as ``"image/*"``. Wildcards never match executables or script-capable
379
+ formats (HTML, SVG, XML): those must be listed explicitly.
380
+ """
381
+
382
+ _deconstruct_module = _PUBLIC
383
+ name = "type"
384
+ stage = "type"
385
+
386
+ types: frozenset[str] = field(default_factory=frozenset)
387
+
388
+ def __post_init__(self) -> None:
389
+ object.__setattr__(self, "types", normalize_allowed_types(self.types))
390
+
391
+ def allows(self, file_type: FileType) -> bool:
392
+ if file_type.mime in self.types:
393
+ return True
394
+ if file_type.executable or file_type.mime in _SCRIPTABLE:
395
+ return False
396
+ return f"{file_type.category}/*" in self.types
397
+
398
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
399
+ file_type = file.file_type
400
+ if file_type is None:
401
+ yield self.violation(
402
+ Code.TYPE_UNKNOWN,
403
+ "The file type could not be identified.",
404
+ declared_type=file.declared_type,
405
+ extension=file.extension,
406
+ )
407
+ elif not self.allows(file_type):
408
+ yield self.violation(
409
+ Code.TYPE_NOT_ALLOWED,
410
+ f"{file_type.label} files are not allowed.",
411
+ detected_type=file_type.mime,
412
+ declared_type=file.declared_type,
413
+ )
414
+
415
+
416
+ @dataclass(frozen=True, slots=True)
417
+ class ExtensionMatchesType(Check):
418
+ """The final extension must be one that belongs to the detected type.
419
+
420
+ This stops content from being stored under a name that makes servers or browsers
421
+ interpret it differently (an image named ``.html``, a script named ``.png``).
422
+ """
423
+
424
+ _deconstruct_module = _PUBLIC
425
+ name = "extension"
426
+ stage = "type"
427
+
428
+ def applies(self, file: InspectedFile) -> bool:
429
+ return file.file_type is not None and bool(file.extension)
430
+
431
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
432
+ file_type = file.file_type
433
+ if file_type is not None and file.extension not in file_type.extensions:
434
+ yield self.violation(
435
+ Code.EXTENSION_MISMATCH,
436
+ f"The file extension '{file.extension}' does not match its content "
437
+ f"({file_type.label}).",
438
+ extension=file.extension,
439
+ detected_type=file_type.mime,
440
+ expected=list(file_type.extensions),
441
+ )
442
+
443
+
444
+ @dataclass(frozen=True, slots=True)
445
+ class DeclaredTypeMatches(Check):
446
+ """The client's ``Content-Type`` must agree with the detected type.
447
+
448
+ Off by default: browsers derive this header from the OS (Windows labels CSV
449
+ ``application/vnd.ms-excel``), so it causes false positives without adding
450
+ protection beyond content detection.
451
+ """
452
+
453
+ _deconstruct_module = _PUBLIC
454
+ name = "declared_type"
455
+ stage = "type"
456
+
457
+ ignored: frozenset[str] = frozenset({"application/octet-stream", "binary/octet-stream"})
458
+
459
+ def applies(self, file: InspectedFile) -> bool:
460
+ declared = normalize_mime(file.declared_type or "")
461
+ return file.file_type is not None and bool(declared) and declared not in self.ignored
462
+
463
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
464
+ file_type = file.file_type
465
+ declared = file.declared_type or ""
466
+ if file_type is None or file_type.matches(declared):
467
+ return
468
+ parent = get_type(file_type.parent) if file_type.parent else None
469
+ if parent is not None and parent.matches(declared):
470
+ return
471
+ yield self.violation(
472
+ Code.DECLARED_TYPE_MISMATCH,
473
+ "The declared content type does not match the file's content.",
474
+ declared_type=declared,
475
+ detected_type=file_type.mime,
476
+ )
@@ -0,0 +1,107 @@
1
+ """Raster image integrity via Pillow (``uv add "uploadshield[images]"``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from uploadshield.core.check import Check
9
+ from uploadshield.core.violations import Code, Violation
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+
14
+ from uploadshield.core.upload import InspectedFile
15
+
16
+ __all__ = ["PILLOW_FORMATS", "ImageIntegrity", "pillow_supports"]
17
+
18
+ #: Detected MIME type -> Pillow format names that may legitimately decode it.
19
+ PILLOW_FORMATS: dict[str, tuple[str, ...]] = {
20
+ "image/jpeg": ("JPEG", "MPO"),
21
+ "image/png": ("PNG",),
22
+ "image/gif": ("GIF",),
23
+ "image/webp": ("WEBP",),
24
+ "image/bmp": ("BMP", "DIB"),
25
+ "image/tiff": ("TIFF",),
26
+ "image/vnd.microsoft.icon": ("ICO",),
27
+ "image/avif": ("AVIF",),
28
+ "image/heic": ("HEIF",),
29
+ }
30
+
31
+
32
+ def pillow_supports(mime: str) -> bool:
33
+ """Whether the installed Pillow has a decoder for ``mime``."""
34
+ try:
35
+ from PIL import Image
36
+ except ImportError:
37
+ return False
38
+ Image.init()
39
+ return any(fmt in Image.OPEN for fmt in PILLOW_FORMATS.get(mime, ()))
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ImageIntegrity(Check):
44
+ """Decode the image completely and enforce pixel, frame and total-pixel limits.
45
+
46
+ Decoding with the format the *sniffer* detected (not whatever Pillow guesses)
47
+ proves the bytes really are that format. Limits are checked from the header
48
+ before any pixel data is decoded, which defeats decompression bombs. Formats the
49
+ installed Pillow cannot decode (e.g. HEIC without a plugin) are skipped.
50
+ """
51
+
52
+ _deconstruct_module = "uploadshield.checks"
53
+ name = "image"
54
+ requires = ("PIL",)
55
+
56
+ max_pixels: int = 50_000_000
57
+ max_frames: int = 1_000
58
+ #: All frames together: an animation of 100 frames of 4000x4000 pixels is 1.3 MB on
59
+ #: the wire but costs minutes and gigabytes to re-encode.
60
+ max_total_pixels: int = 100_000_000
61
+
62
+ def applies(self, file: InspectedFile) -> bool:
63
+ file_type = file.file_type
64
+ return file_type is not None and pillow_supports(file_type.mime)
65
+
66
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
67
+ if (found := self._inspect(file)) is not None:
68
+ yield found
69
+
70
+ def _inspect(self, file: InspectedFile) -> Violation | None:
71
+ from PIL import Image
72
+
73
+ if file.file_type is None: # pragma: no cover - excluded by applies()
74
+ return None
75
+ formats = PILLOW_FORMATS[file.file_type.mime]
76
+ try:
77
+ with file.open() as fh, Image.open(fh, formats=formats) as image:
78
+ width, height = image.size
79
+ frames = getattr(image, "n_frames", 1)
80
+ if (
81
+ width * height > self.max_pixels
82
+ or frames > self.max_frames
83
+ or width * height * frames > self.max_total_pixels
84
+ ):
85
+ return self.violation(
86
+ Code.IMAGE_TOO_LARGE,
87
+ "The image dimensions are too large.",
88
+ width=width,
89
+ height=height,
90
+ frames=frames,
91
+ max_pixels=self.max_pixels,
92
+ )
93
+ image.verify()
94
+ # verify() leaves the image unusable; decode again to catch truncation.
95
+ with file.open() as fh, Image.open(fh, formats=formats) as image:
96
+ image.load()
97
+ except Image.DecompressionBombError as exc:
98
+ return self.violation(
99
+ Code.IMAGE_TOO_LARGE, "The image dimensions are too large.", error=str(exc)
100
+ )
101
+ except Exception as exc: # any decoder failure means "corrupt"
102
+ return self.violation(
103
+ Code.IMAGE_CORRUPT,
104
+ "The image is damaged or not a valid image file.",
105
+ error=f"{type(exc).__name__}: {exc}",
106
+ )
107
+ return None