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,49 @@
1
+ """Content-aware validation and sanitization of file uploads.
2
+
3
+ The framework-agnostic engine lives here; Django integration lives in
4
+ :mod:`uploadshield.django`.
5
+
6
+ >>> from uploadshield import Policy, Upload, validate, MB
7
+ >>> policy = Policy(allowed_types={"image/png"}, max_size=1 * MB)
8
+ >>> result = validate(Upload.from_bytes(b"hello", "notes.png"), policy)
9
+ >>> result.ok, result.codes
10
+ (False, ('type_not_allowed',))
11
+ """
12
+
13
+ from . import checks, presets, sanitizers
14
+ from .core.result import SanitizedFile, ValidationResult
15
+ from .core.sniffing import BuiltinSniffer, ChainedSniffer, MagicSniffer, Sniffer
16
+ from .core.types import GB, KB, MB, FileType, GiB, KiB, MiB, get_type, register_type
17
+ from .core.upload import InspectedFile, Upload
18
+ from .core.violations import Code, Violation
19
+ from .engine import validate
20
+ from .policy import Policy
21
+
22
+ __version__ = "2.0.0"
23
+
24
+ __all__ = [
25
+ "GB",
26
+ "KB",
27
+ "MB",
28
+ "BuiltinSniffer",
29
+ "ChainedSniffer",
30
+ "Code",
31
+ "FileType",
32
+ "GiB",
33
+ "InspectedFile",
34
+ "KiB",
35
+ "MagicSniffer",
36
+ "MiB",
37
+ "Policy",
38
+ "SanitizedFile",
39
+ "Sniffer",
40
+ "Upload",
41
+ "ValidationResult",
42
+ "Violation",
43
+ "checks",
44
+ "get_type",
45
+ "presets",
46
+ "register_type",
47
+ "sanitizers",
48
+ "validate",
49
+ ]
@@ -0,0 +1,17 @@
1
+ """``python -m uploadshield``: the same as the ``uploadshield`` command.
2
+
3
+ ``python -m`` puts the current directory first on the import path; the console script
4
+ does not. Remove it, so scanning a folder of uploads cannot import code planted there
5
+ (a ``PIL/`` package, say). Commands that import project code add it back, after
6
+ installed packages.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ if sys.path and sys.path[0] in ("", str(Path.cwd())):
13
+ del sys.path[0]
14
+
15
+ from uploadshield.cli import main
16
+
17
+ raise SystemExit(main())
@@ -0,0 +1,65 @@
1
+ """Built-in validation checks.
2
+
3
+ Every check is a small frozen dataclass: its fields are its configuration, ``applies``
4
+ decides whether it is relevant for a file, and ``run`` yields violations. Write your
5
+ own by subclassing :class:`~uploadshield.core.check.Check`.
6
+ """
7
+
8
+ from uploadshield.core.check import Check
9
+
10
+ from .archive import ArchiveSafety
11
+ from .basic import (
12
+ DANGEROUS_EXTENSIONS,
13
+ DANGEROUS_NAMES,
14
+ WINDOWS_RESERVED_NAMES,
15
+ AllowedTypes,
16
+ DeclaredTypeMatches,
17
+ ExtensionMatchesType,
18
+ FilenameCheck,
19
+ FileSize,
20
+ )
21
+ from .image import ImageIntegrity
22
+ from .magika import DEFAULT_MAGIKA_FORBIDDEN, MAGIKA_EQUIVALENTS, MagikaCheck
23
+ from .markup import EmbeddedContent, SvgActiveContent
24
+ from .office import OfficeMacros
25
+ from .pdf import DEFAULT_PDF_KEYWORDS, PdfActiveContent
26
+ from .scanners import ClamAV, YaraRules
27
+ from .xml import XmlSafety
28
+
29
+ __all__ = [
30
+ "DANGEROUS_EXTENSIONS",
31
+ "DANGEROUS_NAMES",
32
+ "DEFAULT_CONTENT_CHECKS",
33
+ "DEFAULT_MAGIKA_FORBIDDEN",
34
+ "DEFAULT_PDF_KEYWORDS",
35
+ "MAGIKA_EQUIVALENTS",
36
+ "WINDOWS_RESERVED_NAMES",
37
+ "AllowedTypes",
38
+ "ArchiveSafety",
39
+ "Check",
40
+ "ClamAV",
41
+ "DeclaredTypeMatches",
42
+ "EmbeddedContent",
43
+ "ExtensionMatchesType",
44
+ "FileSize",
45
+ "FilenameCheck",
46
+ "ImageIntegrity",
47
+ "MagikaCheck",
48
+ "OfficeMacros",
49
+ "PdfActiveContent",
50
+ "SvgActiveContent",
51
+ "XmlSafety",
52
+ "YaraRules",
53
+ ]
54
+
55
+ #: Format-specific checks every policy runs by default. Each one only applies to
56
+ #: the formats it understands, so the set is safe to run against any upload.
57
+ DEFAULT_CONTENT_CHECKS: tuple[Check, ...] = (
58
+ ArchiveSafety(),
59
+ OfficeMacros(),
60
+ XmlSafety(),
61
+ PdfActiveContent(),
62
+ SvgActiveContent(),
63
+ EmbeddedContent(),
64
+ ImageIntegrity(),
65
+ )
@@ -0,0 +1,316 @@
1
+ """Safety of ZIP containers - plain archives and ZIP-based formats (OOXML, ODF, EPUB)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import bz2
6
+ import io
7
+ import re
8
+ import stat
9
+ import struct
10
+ import zipfile
11
+ import zlib
12
+ from dataclasses import dataclass, field
13
+ from pathlib import PurePosixPath
14
+ from typing import IO, TYPE_CHECKING, NoReturn
15
+
16
+ from uploadshield.core.check import Check
17
+ from uploadshield.core.containers import ZIP_ERRORS
18
+ from uploadshield.core.types import GiB, MiB
19
+ from uploadshield.core.violations import Code, Violation
20
+
21
+ from .basic import DANGEROUS_EXTENSIONS, DANGEROUS_NAMES, extension_segments
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Iterator
25
+
26
+ from uploadshield.core.upload import InspectedFile
27
+
28
+ __all__ = ["ArchiveSafety"]
29
+
30
+ _DRIVE = re.compile(r"^[a-zA-Z]:")
31
+ _LOCAL_HEADER = b"PK\x03\x04"
32
+ _LOCAL_HEADER_SIZE = 30
33
+ _READ_CHUNK = 1 * MiB
34
+ _INFLATE_CHUNK = 1 * MiB
35
+
36
+
37
+ def _dangerous_member(name: str) -> bool:
38
+ """A member that servers would execute or obey once the archive is extracted."""
39
+ basename = name.replace("\\", "/").rsplit("/", 1)[-1].lower()
40
+ return basename in DANGEROUS_NAMES or bool(
41
+ set(extension_segments(basename)) & DANGEROUS_EXTENSIONS
42
+ )
43
+
44
+
45
+ def _unsafe_path(name: str) -> bool:
46
+ normalized = name.replace("\\", "/")
47
+ return (
48
+ normalized.startswith("/")
49
+ or bool(_DRIVE.match(normalized))
50
+ or ".." in PurePosixPath(normalized).parts
51
+ or "\x00" in name
52
+ )
53
+
54
+
55
+ class _Stop(Exception): # noqa: N818 - control flow, not an error
56
+ """The walk found enough to refuse the archive."""
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class ArchiveSafety(Check):
61
+ """Refuse ZIP bombs, path traversal ("zip slip"), symlinks, encrypted members, and
62
+ members that servers would execute or obey once extracted (``shell.php``,
63
+ ``.htaccess``; ``check_member_names=False`` to allow them).
64
+
65
+ Sizes are *measured*, not taken from the headers (OWASP: "secure ZIP size
66
+ calculation"): every member is inflated as a stream - nothing is stored - and must
67
+ expand to exactly the size and CRC its header declares. A header that understates
68
+ the size is refused as soon as the extra byte appears, so lying archives cost no more
69
+ than they claim. Archives inside archives (``42.zip``, documents embedded in
70
+ documents) are walked the same way up to ``max_depth`` levels, sharing one entry
71
+ and byte budget.
72
+
73
+ The cheap declared-size checks still run first, from the central directory alone:
74
+ entry count, total size, compression ratio and *overlapping entries* (the trick
75
+ behind non-recursive bombs). ``verify_sizes=False`` restores that header-only mode.
76
+ """
77
+
78
+ _deconstruct_module = "uploadshield.checks"
79
+ name = "archive"
80
+
81
+ max_entries: int = 10_000
82
+ max_uncompressed_bytes: int = 1 * GiB
83
+ max_ratio: int = 100
84
+ ratio_threshold_bytes: int = 10 * MiB
85
+ allow_encrypted: bool = False
86
+ verify_sizes: bool = True
87
+ check_member_names: bool = True
88
+ max_depth: int = 3
89
+ max_nested_bytes: int = 64 * MiB
90
+
91
+ def applies(self, file: InspectedFile) -> bool:
92
+ return file.file_type is not None and file.file_type.container == "zip"
93
+
94
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
95
+ walk = _Walk(self)
96
+ try:
97
+ with file.open() as fh:
98
+ try:
99
+ zf = zipfile.ZipFile(fh)
100
+ except ZIP_ERRORS:
101
+ walk.stop(Code.ARCHIVE_CORRUPT, "The archive is damaged or unreadable.")
102
+ walk.archive(zf, fh, file.size, depth=0, prefix="")
103
+ except _Stop:
104
+ pass
105
+ yield from walk.found
106
+
107
+
108
+ @dataclass
109
+ class _Walk:
110
+ check: ArchiveSafety
111
+ found: list[Violation] = field(default_factory=list)
112
+ entries: int = 0
113
+ declared: int = 0
114
+ inflated: int = 0
115
+
116
+ def note(self, code: Code, message: str, **details: object) -> None:
117
+ """Record a violation and keep walking (more problems may follow)."""
118
+ self.found.append(self.check.violation(code, message, **details))
119
+
120
+ def stop(self, code: Code, message: str, **details: object) -> NoReturn:
121
+ """Record a violation that ends the walk: nothing after it can be trusted."""
122
+ self.note(code, message, **details)
123
+ raise _Stop
124
+
125
+ def archive(
126
+ self, zf: zipfile.ZipFile, fh: IO[bytes], size: int, *, depth: int, prefix: str
127
+ ) -> None:
128
+ check = self.check
129
+ entries = zf.infolist()
130
+ self.entries += len(entries)
131
+ declared = sum(e.file_size for e in entries)
132
+ self.declared += declared
133
+ compressed = sum(e.compress_size for e in entries)
134
+ overlapping = compressed + _LOCAL_HEADER_SIZE * len(entries) > size
135
+ ratio = declared / max(compressed, 1)
136
+ if (
137
+ self.entries > check.max_entries
138
+ or self.declared > check.max_uncompressed_bytes
139
+ or overlapping
140
+ or (declared > check.ratio_threshold_bytes and ratio > check.max_ratio)
141
+ ):
142
+ self.stop(
143
+ Code.ARCHIVE_BOMB,
144
+ "The archive expands to an unsafe size.",
145
+ entries=self.entries,
146
+ uncompressed=self.declared,
147
+ ratio=round(ratio, 1),
148
+ overlapping=overlapping,
149
+ archive=prefix or None,
150
+ )
151
+
152
+ unsafe = [prefix + e.filename for e in entries if _unsafe_path(e.filename)]
153
+ links = [prefix + e.filename for e in entries if stat.S_ISLNK(e.external_attr >> 16)]
154
+ if unsafe or links:
155
+ self.note(
156
+ Code.ARCHIVE_UNSAFE_PATH,
157
+ "The archive contains unsafe paths or links.",
158
+ paths=(unsafe + links)[:10],
159
+ )
160
+ if check.check_member_names and (
161
+ dangerous := [
162
+ prefix + e.filename
163
+ for e in entries
164
+ if not e.is_dir() and _dangerous_member(e.filename)
165
+ ]
166
+ ):
167
+ self.note(
168
+ Code.ARCHIVE_DANGEROUS_MEMBER,
169
+ "The archive contains files that servers could execute.",
170
+ members=dangerous[:10],
171
+ )
172
+ encrypted = [e for e in entries if e.flag_bits & 0x1]
173
+ if encrypted and not check.allow_encrypted:
174
+ self.note(
175
+ Code.ARCHIVE_ENCRYPTED,
176
+ "Encrypted archives cannot be checked and are not allowed.",
177
+ )
178
+ return # their contents cannot be measured
179
+ if not check.verify_sizes:
180
+ return
181
+ for info in entries:
182
+ if info.is_dir() or info.flag_bits & 0x1:
183
+ continue
184
+ self.member(zf, fh, info, depth=depth, prefix=prefix)
185
+
186
+ def member(
187
+ self, zf: zipfile.ZipFile, fh: IO[bytes], info: zipfile.ZipInfo, *, depth: int, prefix: str
188
+ ) -> None:
189
+ check = self.check
190
+ name = prefix + info.filename
191
+ could_nest = info.file_size <= check.max_nested_bytes
192
+ limit = min(info.file_size, self.remaining()) + 1
193
+ measured = _inflate(zf, fh, info, limit=limit, keep=could_nest)
194
+ if measured is None:
195
+ self.stop(Code.ARCHIVE_CORRUPT, "The archive is damaged or unreadable.", member=name)
196
+ actual, crc, data = measured
197
+ self.inflated += actual
198
+ if actual > info.file_size or self.inflated > check.max_uncompressed_bytes:
199
+ self.stop(
200
+ Code.ARCHIVE_BOMB,
201
+ "The archive expands to an unsafe size.",
202
+ member=name,
203
+ declared=info.file_size,
204
+ measured_at_least=actual,
205
+ )
206
+ if actual != info.file_size or crc != info.CRC:
207
+ self.stop(Code.ARCHIVE_CORRUPT, "The archive is damaged or unreadable.", member=name)
208
+ if data is None:
209
+ if not could_nest and _starts_as_zip(zf, fh, info):
210
+ self.stop(
211
+ Code.ARCHIVE_BOMB,
212
+ "The archive contains an archive too large to inspect.",
213
+ member=name,
214
+ )
215
+ return
216
+ if depth + 1 > check.max_depth:
217
+ self.stop(
218
+ Code.ARCHIVE_BOMB, "The archive contains too many nested archives.", member=name
219
+ )
220
+ nested = io.BytesIO(data)
221
+ try:
222
+ inner = zipfile.ZipFile(nested)
223
+ except ZIP_ERRORS:
224
+ return # starts like a ZIP but is none: an ordinary member
225
+ with inner:
226
+ self.archive(inner, nested, len(data), depth=depth + 1, prefix=f"{name}/")
227
+
228
+ def remaining(self) -> int:
229
+ return self.check.max_uncompressed_bytes - self.inflated
230
+
231
+
232
+ def _starts_as_zip(zf: zipfile.ZipFile, fh: IO[bytes], info: zipfile.ZipInfo) -> bool:
233
+ measured = _inflate(zf, fh, info, limit=len(_LOCAL_HEADER), keep=True)
234
+ return measured is not None and measured[2] is not None
235
+
236
+
237
+ def _data_offset(fh: IO[bytes], info: zipfile.ZipInfo) -> int | None:
238
+ if info.header_offset < 0: # a damaged central directory
239
+ return None
240
+ fh.seek(info.header_offset)
241
+ header = fh.read(_LOCAL_HEADER_SIZE)
242
+ if len(header) < _LOCAL_HEADER_SIZE or not header.startswith(_LOCAL_HEADER):
243
+ return None
244
+ name_length, extra_length = struct.unpack_from("<HH", header, 26)
245
+ # Extractors that follow the local header must see the name that was checked:
246
+ # a local "config.php" behind a central "config.txt" is refused like zipfile does.
247
+ local_name = fh.read(name_length)
248
+ encoding = "utf-8" if info.flag_bits & 0x800 else "cp437"
249
+ if local_name.decode(encoding, "replace") != info.orig_filename:
250
+ return None
251
+ return info.header_offset + _LOCAL_HEADER_SIZE + name_length + extra_length
252
+
253
+
254
+ def _compressed(fh: IO[bytes], start: int, length: int) -> Iterator[bytes]:
255
+ fh.seek(start)
256
+ while length > 0:
257
+ chunk = fh.read(min(_READ_CHUNK, length))
258
+ if not chunk:
259
+ raise EOFError("truncated member")
260
+ length -= len(chunk)
261
+ yield chunk
262
+
263
+
264
+ def _output(
265
+ zf: zipfile.ZipFile, fh: IO[bytes], info: zipfile.ZipInfo, start: int
266
+ ) -> Iterator[bytes]:
267
+ """The member's uncompressed bytes, in bounded pieces, decompressed lazily."""
268
+ chunks = _compressed(fh, start, info.compress_size)
269
+ if info.compress_type == zipfile.ZIP_STORED:
270
+ yield from chunks
271
+ elif info.compress_type == zipfile.ZIP_DEFLATED:
272
+ inflate = zlib.decompressobj(-zlib.MAX_WBITS)
273
+ for chunk in chunks:
274
+ yield inflate.decompress(chunk, _INFLATE_CHUNK)
275
+ while inflate.unconsumed_tail:
276
+ yield inflate.decompress(inflate.unconsumed_tail, _INFLATE_CHUNK)
277
+ yield inflate.flush()
278
+ elif info.compress_type == zipfile.ZIP_BZIP2:
279
+ unbzip = bz2.BZ2Decompressor()
280
+ for chunk in chunks:
281
+ yield unbzip.decompress(chunk, _INFLATE_CHUNK)
282
+ while not unbzip.eof and not unbzip.needs_input:
283
+ yield unbzip.decompress(b"", _INFLATE_CHUNK)
284
+ else: # LZMA and others: zipfile reads at most the declared size (CRC-checked)
285
+ with zf.open(info) as stream:
286
+ while piece := stream.read(_INFLATE_CHUNK):
287
+ yield piece
288
+
289
+
290
+ def _inflate(
291
+ zf: zipfile.ZipFile, fh: IO[bytes], info: zipfile.ZipInfo, *, limit: int, keep: bool
292
+ ) -> tuple[int, int, bytes | None] | None:
293
+ """Stream one member: ``(size, crc32, data)``; ``None`` if it cannot be read.
294
+
295
+ Decompression stops once ``limit`` bytes came out (callers pass one byte more than
296
+ the member may have), so a lying header is caught without inflating the rest.
297
+ ``data`` is kept only if ``keep`` is set *and* the member starts like a ZIP.
298
+ """
299
+ size, crc = 0, 0
300
+ kept: bytearray | None = bytearray() if keep else None
301
+ try:
302
+ start = _data_offset(fh, info)
303
+ if start is None:
304
+ return None
305
+ for piece in _output(zf, fh, info, start):
306
+ size += len(piece)
307
+ crc = zlib.crc32(piece, crc)
308
+ if kept is not None:
309
+ kept += piece
310
+ if len(kept) >= len(_LOCAL_HEADER) and not kept.startswith(_LOCAL_HEADER):
311
+ kept = None # not an archive: nothing to keep
312
+ if size >= limit:
313
+ break
314
+ except (*ZIP_ERRORS, EOFError):
315
+ return None
316
+ return size, crc, bytes(kept) if kept is not None else None