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
uploadshield/__init__.py
ADDED
|
@@ -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
|
+
]
|
uploadshield/__main__.py
ADDED
|
@@ -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
|