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.
- uploadshield/__init__.py +49 -0
- uploadshield/__main__.py +17 -0
- uploadshield/checks/__init__.py +65 -0
- uploadshield/checks/archive.py +316 -0
- uploadshield/checks/basic.py +476 -0
- uploadshield/checks/image.py +107 -0
- uploadshield/checks/magika.py +129 -0
- uploadshield/checks/markup.py +181 -0
- uploadshield/checks/office.py +174 -0
- uploadshield/checks/pdf.py +277 -0
- uploadshield/checks/scanners.py +205 -0
- uploadshield/checks/xml.py +123 -0
- uploadshield/cli/__init__.py +276 -0
- uploadshield/cli/_commands.py +401 -0
- uploadshield/cli/_json.py +80 -0
- uploadshield/cli/_render.py +319 -0
- uploadshield/cli/_scan.py +106 -0
- uploadshield/cli/_style.py +95 -0
- uploadshield/cli/_verdicts.py +363 -0
- uploadshield/cli/_walk.py +159 -0
- uploadshield/core/__init__.py +4 -0
- uploadshield/core/_deconstruct.py +39 -0
- uploadshield/core/check.py +79 -0
- uploadshield/core/containers.py +131 -0
- uploadshield/core/result.py +63 -0
- uploadshield/core/sniffing.py +432 -0
- uploadshield/core/types.py +319 -0
- uploadshield/core/upload.py +192 -0
- uploadshield/core/violations.py +79 -0
- uploadshield/django/__init__.py +36 -0
- uploadshield/django/apps.py +13 -0
- uploadshield/django/checks.py +148 -0
- uploadshield/django/cli.py +36 -0
- uploadshield/django/conf.py +189 -0
- uploadshield/django/decorators.py +204 -0
- uploadshield/django/exceptions.py +39 -0
- uploadshield/django/forms.py +40 -0
- uploadshield/django/handlers.py +322 -0
- uploadshield/django/responses.py +92 -0
- uploadshield/django/service.py +473 -0
- uploadshield/django/signals.py +16 -0
- uploadshield/django/storage.py +204 -0
- uploadshield/django/templates/uploadshield/rejected.html +15 -0
- uploadshield/django/validators.py +48 -0
- uploadshield/django/views.py +40 -0
- uploadshield/engine.py +230 -0
- uploadshield/fastapi/__init__.py +23 -0
- uploadshield/fastapi/shield.py +299 -0
- uploadshield/flask/__init__.py +42 -0
- uploadshield/flask/core.py +359 -0
- uploadshield/flask/decorators.py +69 -0
- uploadshield/flask/wtforms.py +58 -0
- uploadshield/policy.py +137 -0
- uploadshield/presets.py +65 -0
- uploadshield/py.typed +0 -0
- uploadshield/sanitizers.py +132 -0
- uploadshield/web.py +218 -0
- uploadshield-2.0.0.dist-info/METADATA +239 -0
- uploadshield-2.0.0.dist-info/RECORD +62 -0
- uploadshield-2.0.0.dist-info/WHEEL +4 -0
- uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
- 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
|