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,131 @@
|
|
|
1
|
+
"""Shared, cached access to container formats (ZIP) used by several checks.
|
|
2
|
+
|
|
3
|
+
The central directory is parsed once per upload and kept in ``InspectedFile.cache``
|
|
4
|
+
together with an index of member offsets. Every helper below then costs only the
|
|
5
|
+
member it reads: documents with thousands of parts are walked in linear time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import bisect
|
|
11
|
+
import struct
|
|
12
|
+
import zipfile
|
|
13
|
+
import zlib
|
|
14
|
+
from typing import IO, TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from .upload import InspectedFile
|
|
20
|
+
|
|
21
|
+
__all__ = ["ZIP_ERRORS", "zip_entries", "zip_head", "zip_member", "zip_read", "zip_search"]
|
|
22
|
+
|
|
23
|
+
ZIP_ERRORS = (
|
|
24
|
+
zipfile.BadZipFile,
|
|
25
|
+
zipfile.LargeZipFile,
|
|
26
|
+
OSError,
|
|
27
|
+
ValueError,
|
|
28
|
+
EOFError,
|
|
29
|
+
NotImplementedError,
|
|
30
|
+
KeyError,
|
|
31
|
+
RuntimeError,
|
|
32
|
+
zlib.error,
|
|
33
|
+
struct.error,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_LOCAL_HEADER = 30
|
|
37
|
+
_CHUNK = 1024 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Archive:
|
|
41
|
+
"""An open ZIP over the upload's stream, its members by name and an offset index."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, stream: IO[bytes]) -> None:
|
|
44
|
+
self.zip = zipfile.ZipFile(stream)
|
|
45
|
+
self.entries = self.zip.infolist()
|
|
46
|
+
self.by_name = {e.filename: e for e in self.entries}
|
|
47
|
+
self.offsets = sorted(e.header_offset for e in self.entries)
|
|
48
|
+
|
|
49
|
+
def overlaps(self, info: zipfile.ZipInfo) -> bool:
|
|
50
|
+
"""Whether another member starts inside ``info``'s data (overlapping-entry bombs)."""
|
|
51
|
+
end = info.header_offset + _LOCAL_HEADER + len(info.filename.encode()) + info.compress_size
|
|
52
|
+
index = bisect.bisect_right(self.offsets, info.header_offset)
|
|
53
|
+
duplicate_start = index >= 2 and self.offsets[index - 2] == info.header_offset
|
|
54
|
+
return duplicate_start or (index < len(self.offsets) and self.offsets[index] < end)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _archive(file: InspectedFile) -> _Archive | None:
|
|
58
|
+
key = "zip.archive"
|
|
59
|
+
if key not in file.cache:
|
|
60
|
+
try:
|
|
61
|
+
with file.open() as fh:
|
|
62
|
+
file.cache[key] = _Archive(fh)
|
|
63
|
+
except ZIP_ERRORS:
|
|
64
|
+
file.cache[key] = None
|
|
65
|
+
return file.cache[key]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def zip_entries(file: InspectedFile) -> list[zipfile.ZipInfo] | None:
|
|
69
|
+
"""The central-directory listing, or ``None`` if the file is not a readable ZIP."""
|
|
70
|
+
archive = _archive(file)
|
|
71
|
+
return archive.entries if archive is not None else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _member(file: InspectedFile, member: str) -> tuple[_Archive, zipfile.ZipInfo] | None:
|
|
75
|
+
archive = _archive(file)
|
|
76
|
+
if archive is None or (info := archive.by_name.get(member)) is None:
|
|
77
|
+
return None
|
|
78
|
+
return None if archive.overlaps(info) else (archive, info)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def zip_member(file: InspectedFile, member: str) -> zipfile.ZipInfo | None:
|
|
82
|
+
"""A member that can be read safely (exists, does not overlap another one)."""
|
|
83
|
+
found = _member(file, member)
|
|
84
|
+
return found[1] if found is not None else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read(file: InspectedFile, member: str, length: int) -> bytes | None:
|
|
88
|
+
if (found := _member(file, member)) is None:
|
|
89
|
+
return None
|
|
90
|
+
archive, info = found
|
|
91
|
+
try:
|
|
92
|
+
with file.open(), archive.zip.open(info) as stream:
|
|
93
|
+
return stream.read(length)
|
|
94
|
+
except ZIP_ERRORS:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def zip_head(file: InspectedFile, member: str, length: int) -> bytes | None:
|
|
99
|
+
"""The first ``length`` uncompressed bytes of a member (``None`` if unreadable)."""
|
|
100
|
+
return _read(file, member, length)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def zip_search(
|
|
104
|
+
file: InspectedFile, member: str, pattern: re.Pattern[bytes], *, overlap: int = 4096
|
|
105
|
+
) -> bool:
|
|
106
|
+
"""Whether ``pattern`` occurs in a member, streamed in chunks (members may be large)."""
|
|
107
|
+
if (found := _member(file, member)) is None:
|
|
108
|
+
return False
|
|
109
|
+
archive, info = found
|
|
110
|
+
carry = b""
|
|
111
|
+
try:
|
|
112
|
+
with file.open(), archive.zip.open(info) as stream:
|
|
113
|
+
while chunk := stream.read(_CHUNK):
|
|
114
|
+
window = carry + chunk
|
|
115
|
+
if pattern.search(window):
|
|
116
|
+
return True
|
|
117
|
+
carry = window[-overlap:]
|
|
118
|
+
except ZIP_ERRORS:
|
|
119
|
+
return False
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def zip_read(file: InspectedFile, member: str, limit: int) -> bytes | None:
|
|
124
|
+
"""Read one member, decompressing at most ``limit`` bytes.
|
|
125
|
+
|
|
126
|
+
Returns ``None`` if the member is missing, unreadable, larger than ``limit``, or
|
|
127
|
+
overlaps another entry (such archives are refused by ``ArchiveSafety``; reading
|
|
128
|
+
them would also make :mod:`zipfile` emit warnings).
|
|
129
|
+
"""
|
|
130
|
+
data = _read(file, member, limit + 1)
|
|
131
|
+
return None if data is None or len(data) > limit else data
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The outcome of validating one file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .types import FileType
|
|
10
|
+
from .violations import Violation
|
|
11
|
+
|
|
12
|
+
__all__ = ["SanitizedFile", "ValidationResult"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class SanitizedFile:
|
|
17
|
+
"""The sanitized replacement for an upload.
|
|
18
|
+
|
|
19
|
+
``data`` is ``None`` when only metadata (e.g. the name) changed and the original
|
|
20
|
+
content can be kept as-is.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
content_type: str
|
|
25
|
+
data: bytes | None
|
|
26
|
+
applied: tuple[str, ...]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ValidationResult:
|
|
31
|
+
name: str
|
|
32
|
+
field: str | None
|
|
33
|
+
size: int
|
|
34
|
+
file_type: FileType | None
|
|
35
|
+
declared_type: str | None
|
|
36
|
+
violations: tuple[Violation, ...]
|
|
37
|
+
checks_run: tuple[str, ...]
|
|
38
|
+
sanitized: SanitizedFile | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def ok(self) -> bool:
|
|
42
|
+
return not self.violations
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def codes(self) -> tuple[str, ...]:
|
|
46
|
+
return tuple(str(v.code) for v in self.violations)
|
|
47
|
+
|
|
48
|
+
def as_dict(self, *, include_details: bool = False) -> dict[str, Any]:
|
|
49
|
+
data: dict[str, Any] = {
|
|
50
|
+
"field": self.field,
|
|
51
|
+
"name": self.name,
|
|
52
|
+
"ok": self.ok,
|
|
53
|
+
"errors": [v.as_dict(include_details=include_details) for v in self.violations],
|
|
54
|
+
}
|
|
55
|
+
if include_details:
|
|
56
|
+
data |= {
|
|
57
|
+
"size": self.size,
|
|
58
|
+
"detected_type": self.file_type.mime if self.file_type else None,
|
|
59
|
+
"declared_type": self.declared_type,
|
|
60
|
+
"checks_run": list(self.checks_run),
|
|
61
|
+
"sanitized": list(self.sanitized.applied) if self.sanitized else [],
|
|
62
|
+
}
|
|
63
|
+
return data
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Content-based file type detection ("sniffing").
|
|
2
|
+
|
|
3
|
+
The only trustworthy evidence about an upload is its bytes. A :class:`Sniffer` maps
|
|
4
|
+
content to a registered :class:`~uploadshield.core.types.FileType` or returns
|
|
5
|
+
``None`` when the content cannot be positively identified - unidentified content is
|
|
6
|
+
never guessed from the client-supplied name or ``Content-Type``.
|
|
7
|
+
|
|
8
|
+
Short magic numbers (``BM``, ``MZ``, ...) are verified with additional header fields so
|
|
9
|
+
that ordinary text does not get misclassified as a binary format.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import struct
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from ._deconstruct import Deconstructible
|
|
21
|
+
from .containers import zip_entries, zip_read
|
|
22
|
+
from .types import FileType, get_type
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .upload import InspectedFile
|
|
26
|
+
|
|
27
|
+
__all__ = ["BuiltinSniffer", "ChainedSniffer", "MagicSniffer", "Sniffer", "is_binary"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class Sniffer(Protocol):
|
|
32
|
+
"""Anything that can identify a file's type from its content."""
|
|
33
|
+
|
|
34
|
+
def sniff(self, file: InspectedFile) -> FileType | None: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------- #
|
|
38
|
+
# Text / binary classification (after the WHATWG MIME Sniffing Standard)
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
|
|
41
|
+
#: Bytes that never occur in text (WHATWG "binary data byte").
|
|
42
|
+
_BINARY_BYTES = bytes([*range(0x00, 0x09), 0x0B, *range(0x0E, 0x1B), *range(0x1C, 0x20)])
|
|
43
|
+
_TEXT_BYTES = bytes(b for b in range(256) if b not in _BINARY_BYTES)
|
|
44
|
+
|
|
45
|
+
#: Tag openings that make browsers render a resource as HTML.
|
|
46
|
+
_HTML_OPENERS = re.compile(
|
|
47
|
+
rb"^(?:<!doctype\s+html|<html|<head|<script|<iframe|<h1|<div|<font|<table|<a|<style"
|
|
48
|
+
rb"|<title|<b|<body|<br|<p|<!--)(?:[\s>]|$)",
|
|
49
|
+
)
|
|
50
|
+
_WHITESPACE = b" \t\r\n\f"
|
|
51
|
+
_ELEMENT_NAME = re.compile(rb"<([a-z_][a-z0-9_.:-]*)")
|
|
52
|
+
_DOCTYPE_NAME = re.compile(rb"<!doctype\s+([a-z_][a-z0-9_.:-]*)")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _root_element(text: bytes) -> tuple[bytes | None, bytes | None]:
|
|
56
|
+
"""Return ``(doctype_name, root_element_name)`` of a markup document head."""
|
|
57
|
+
doctype: bytes | None = None
|
|
58
|
+
rest = text.lstrip(_WHITESPACE)
|
|
59
|
+
while rest.startswith(b"<?") or rest.startswith(b"<!"):
|
|
60
|
+
if rest.startswith(b"<!--"):
|
|
61
|
+
end = rest.find(b"-->")
|
|
62
|
+
skip = end + 3
|
|
63
|
+
elif rest.startswith(b"<?"):
|
|
64
|
+
end = rest.find(b"?>")
|
|
65
|
+
skip = end + 2
|
|
66
|
+
else:
|
|
67
|
+
if match := _DOCTYPE_NAME.match(rest):
|
|
68
|
+
doctype = match.group(1)
|
|
69
|
+
end = rest.find(b">")
|
|
70
|
+
skip = end + 1
|
|
71
|
+
if end == -1:
|
|
72
|
+
return doctype, None
|
|
73
|
+
rest = rest[skip:].lstrip(_WHITESPACE)
|
|
74
|
+
match = _ELEMENT_NAME.match(rest)
|
|
75
|
+
root = match.group(1).rsplit(b":", 1)[-1] if match else None
|
|
76
|
+
return doctype, root
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_binary(data: bytes) -> bool:
|
|
80
|
+
"""Whether ``data`` contains bytes that never appear in text."""
|
|
81
|
+
return bool(data.translate(None, _TEXT_BYTES))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _decode_text_head(head: bytes) -> bytes | None:
|
|
85
|
+
"""Return a lowercase, BOM-free, ASCII-compatible view of a text head or ``None``."""
|
|
86
|
+
if head.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
87
|
+
codec = "utf-16-le" if head.startswith(b"\xff\xfe") else "utf-16-be"
|
|
88
|
+
body = head[2:]
|
|
89
|
+
body = body[: len(body) - (len(body) % 2)]
|
|
90
|
+
try:
|
|
91
|
+
text = body.decode(codec)
|
|
92
|
+
except UnicodeDecodeError:
|
|
93
|
+
return None
|
|
94
|
+
encoded = text.encode("utf-8", "replace")
|
|
95
|
+
return None if is_binary(encoded) else encoded.lower()
|
|
96
|
+
head = head.removeprefix(b"\xef\xbb\xbf")
|
|
97
|
+
return None if is_binary(head) else head.lower()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _classify_text(head: bytes) -> str | None:
|
|
101
|
+
text = _decode_text_head(head)
|
|
102
|
+
if text is None:
|
|
103
|
+
return None
|
|
104
|
+
start = text.lstrip(_WHITESPACE)
|
|
105
|
+
if start.startswith((b"<?php", b"<?=")):
|
|
106
|
+
return "application/x-httpd-php"
|
|
107
|
+
if start.startswith(b"#!"):
|
|
108
|
+
return "application/x-sh"
|
|
109
|
+
if not start.startswith(b"<"):
|
|
110
|
+
return "text/plain"
|
|
111
|
+
doctype, root = _root_element(start)
|
|
112
|
+
if b"svg" in (doctype, root):
|
|
113
|
+
return "image/svg+xml"
|
|
114
|
+
if b"html" in (doctype, root) or _HTML_OPENERS.match(start):
|
|
115
|
+
return "text/html"
|
|
116
|
+
if start.startswith(b"<?xml"):
|
|
117
|
+
return "application/xml"
|
|
118
|
+
return "text/plain"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
# Binary signatures
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
_PREFIXES: tuple[tuple[bytes, str], ...] = (
|
|
126
|
+
(b"\xff\xd8\xff", "image/jpeg"),
|
|
127
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
128
|
+
(b"GIF87a", "image/gif"),
|
|
129
|
+
(b"GIF89a", "image/gif"),
|
|
130
|
+
(b"II*\x00", "image/tiff"),
|
|
131
|
+
(b"MM\x00*", "image/tiff"),
|
|
132
|
+
(b"%PDF-", "application/pdf"),
|
|
133
|
+
(b"{\\rtf", "application/rtf"),
|
|
134
|
+
(b"%!PS", "application/postscript"),
|
|
135
|
+
(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "application/x-ole-storage"),
|
|
136
|
+
(b"\x1f\x8b\x08", "application/gzip"),
|
|
137
|
+
(b"\xfd7zXZ\x00", "application/x-xz"),
|
|
138
|
+
(b"7z\xbc\xaf\x27\x1c", "application/x-7z-compressed"),
|
|
139
|
+
(b"Rar!\x1a\x07", "application/vnd.rar"),
|
|
140
|
+
(b"fLaC", "audio/flac"),
|
|
141
|
+
(b"OggS\x00", "audio/ogg"),
|
|
142
|
+
(b"\x7fELF", "application/x-elf"),
|
|
143
|
+
(b"\x00asm", "application/wasm"),
|
|
144
|
+
(b"SQLite format 3\x00", "application/vnd.sqlite3"),
|
|
145
|
+
(b"\xfe\xed\xfa\xce", "application/x-mach-binary"),
|
|
146
|
+
(b"\xfe\xed\xfa\xcf", "application/x-mach-binary"),
|
|
147
|
+
(b"\xce\xfa\xed\xfe", "application/x-mach-binary"),
|
|
148
|
+
(b"\xcf\xfa\xed\xfe", "application/x-mach-binary"),
|
|
149
|
+
(b"\x00\x00\x01\xba", "video/mpeg"),
|
|
150
|
+
(b"\x00\x00\x01\xb3", "video/mpeg"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
_RIFF_FORMS = {b"WEBP": "image/webp", b"WAVE": "audio/wav", b"AVI ": "video/x-msvideo"}
|
|
154
|
+
|
|
155
|
+
_FTYP_BRANDS = {
|
|
156
|
+
b"avif": "image/avif",
|
|
157
|
+
b"avis": "image/avif",
|
|
158
|
+
b"heic": "image/heic",
|
|
159
|
+
b"heix": "image/heic",
|
|
160
|
+
b"heim": "image/heic",
|
|
161
|
+
b"heis": "image/heic",
|
|
162
|
+
b"hevc": "image/heic",
|
|
163
|
+
b"hevx": "image/heic",
|
|
164
|
+
b"M4A ": "audio/mp4",
|
|
165
|
+
b"M4B ": "audio/mp4",
|
|
166
|
+
b"qt ": "video/quicktime",
|
|
167
|
+
b"3gp4": "video/3gpp",
|
|
168
|
+
b"3gp5": "video/3gpp",
|
|
169
|
+
b"3gp6": "video/3gpp",
|
|
170
|
+
b"3g2a": "video/3gpp",
|
|
171
|
+
b"isom": "video/mp4",
|
|
172
|
+
b"iso2": "video/mp4",
|
|
173
|
+
b"iso4": "video/mp4",
|
|
174
|
+
b"iso5": "video/mp4",
|
|
175
|
+
b"iso6": "video/mp4",
|
|
176
|
+
b"mp41": "video/mp4",
|
|
177
|
+
b"mp42": "video/mp4",
|
|
178
|
+
b"avc1": "video/mp4",
|
|
179
|
+
b"dash": "video/mp4",
|
|
180
|
+
b"M4V ": "video/mp4",
|
|
181
|
+
b"MSNV": "video/mp4",
|
|
182
|
+
b"f4v ": "video/mp4",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_BMP_DIB_HEADER_SIZES = {12, 40, 52, 56, 64, 108, 124}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _sniff_bmp(head: bytes) -> str | None:
|
|
189
|
+
if len(head) < 18 or head[:2] != b"BM":
|
|
190
|
+
return None
|
|
191
|
+
reserved = head[6:10]
|
|
192
|
+
(dib_size,) = struct.unpack_from("<I", head, 14)
|
|
193
|
+
return "image/bmp" if reserved == b"\x00" * 4 and dib_size in _BMP_DIB_HEADER_SIZES else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _sniff_ico(head: bytes) -> str | None:
|
|
197
|
+
if len(head) < 22 or head[:4] != b"\x00\x00\x01\x00":
|
|
198
|
+
return None
|
|
199
|
+
(count,) = struct.unpack_from("<H", head, 4)
|
|
200
|
+
reserved = head[9]
|
|
201
|
+
(planes,) = struct.unpack_from("<H", head, 10)
|
|
202
|
+
size, offset = struct.unpack_from("<II", head, 14)
|
|
203
|
+
if count and reserved == 0 and planes in (0, 1) and size and offset >= 6 + 16 * count:
|
|
204
|
+
return "image/vnd.microsoft.icon"
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _sniff_riff(head: bytes) -> str | None:
|
|
209
|
+
if head[:4] != b"RIFF" or len(head) < 12:
|
|
210
|
+
return None
|
|
211
|
+
return _RIFF_FORMS.get(head[8:12])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _sniff_ftyp(head: bytes) -> str | None:
|
|
215
|
+
if len(head) < 12 or head[4:8] != b"ftyp":
|
|
216
|
+
return None
|
|
217
|
+
(box_size,) = struct.unpack_from(">I", head, 0)
|
|
218
|
+
brands = [head[8:12]]
|
|
219
|
+
end = min(box_size, len(head))
|
|
220
|
+
brands += [head[i : i + 4] for i in range(16, end - 3, 4)]
|
|
221
|
+
# AVIF/HEIC files often use the generic 'mif1' major brand; look at compatibles.
|
|
222
|
+
for brand in brands:
|
|
223
|
+
if brand in (b"avif", b"avis"):
|
|
224
|
+
return "image/avif"
|
|
225
|
+
for brand in brands:
|
|
226
|
+
if brand in _FTYP_BRANDS:
|
|
227
|
+
return _FTYP_BRANDS[brand]
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _sniff_ebml(head: bytes) -> str | None:
|
|
232
|
+
if head[:4] != b"\x1a\x45\xdf\xa3":
|
|
233
|
+
return None
|
|
234
|
+
window = head[:4096]
|
|
235
|
+
if b"webm" in window:
|
|
236
|
+
return "video/webm"
|
|
237
|
+
if b"matroska" in window:
|
|
238
|
+
return "video/x-matroska"
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _sniff_tar(head: bytes) -> str | None:
|
|
243
|
+
return "application/x-tar" if head[257:262] == b"ustar" else None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _sniff_bzip2(head: bytes) -> str | None:
|
|
247
|
+
if head[:3] == b"BZh" and head[3:4].isdigit() and head[3:4] != b"0":
|
|
248
|
+
block = head[4:10]
|
|
249
|
+
if block in (b"\x31\x41\x59\x26\x53\x59", b"\x17\x72\x45\x38\x50\x90"):
|
|
250
|
+
return "application/x-bzip2"
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _sniff_id3_or_mpeg_audio(head: bytes) -> str | None:
|
|
255
|
+
if head[:3] == b"ID3" and len(head) >= 10 and head[3] in (2, 3, 4) and head[4] != 0xFF:
|
|
256
|
+
return "audio/mpeg"
|
|
257
|
+
if len(head) < 4 or head[0] != 0xFF or (head[1] & 0xE0) != 0xE0:
|
|
258
|
+
return None
|
|
259
|
+
version = (head[1] >> 3) & 0x03
|
|
260
|
+
layer = (head[1] >> 1) & 0x03
|
|
261
|
+
bitrate = head[2] >> 4
|
|
262
|
+
sample_rate = (head[2] >> 2) & 0x03
|
|
263
|
+
if layer == 0 and (head[1] & 0xF6) == 0xF0: # ADTS (AAC) shares the sync word
|
|
264
|
+
return "audio/aac"
|
|
265
|
+
if version == 1 or layer == 0 or bitrate in (0, 0x0F) or sample_rate == 0x03:
|
|
266
|
+
return None
|
|
267
|
+
return "audio/mpeg"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _sniff_cafebabe(head: bytes) -> str | None:
|
|
271
|
+
if head[:4] != b"\xca\xfe\xba\xbe" or len(head) < 8:
|
|
272
|
+
return None
|
|
273
|
+
(value,) = struct.unpack_from(">I", head, 4)
|
|
274
|
+
# Mach-O universal binaries store a small architecture count here; Java class
|
|
275
|
+
# files store (minor << 16 | major) where major >= 45.
|
|
276
|
+
return "application/x-mach-binary" if value < 45 else "application/java-vm"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _sniff_mz(head: bytes, file: InspectedFile) -> str | None:
|
|
280
|
+
if head[:2] != b"MZ":
|
|
281
|
+
return None
|
|
282
|
+
if len(head) >= 0x40:
|
|
283
|
+
(pe_offset,) = struct.unpack_from("<I", head, 0x3C)
|
|
284
|
+
if pe_offset + 4 <= min(file.size, 1 << 20):
|
|
285
|
+
window = file.head(pe_offset + 4)
|
|
286
|
+
if window[pe_offset : pe_offset + 4] == b"PE\x00\x00":
|
|
287
|
+
return "application/vnd.microsoft.portable-executable"
|
|
288
|
+
# A DOS "MZ" executable without a PE header is still an executable, unless the
|
|
289
|
+
# content is plainly text that merely starts with the letters "MZ".
|
|
290
|
+
return "application/vnd.microsoft.portable-executable" if is_binary(head) else None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
|
294
|
+
|
|
295
|
+
_ODF_MIMETYPES = {
|
|
296
|
+
mime.encode(): mime
|
|
297
|
+
for mime in (
|
|
298
|
+
"application/vnd.oasis.opendocument.text",
|
|
299
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
300
|
+
"application/vnd.oasis.opendocument.presentation",
|
|
301
|
+
"application/epub+zip",
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_OOXML_MAIN_PARTS = (
|
|
306
|
+
(
|
|
307
|
+
b"application/vnd.ms-word.document.macroenabled.main+xml",
|
|
308
|
+
"application/vnd.ms-word.document.macroenabled.12",
|
|
309
|
+
),
|
|
310
|
+
(
|
|
311
|
+
b"application/vnd.ms-excel.sheet.macroenabled.main+xml",
|
|
312
|
+
"application/vnd.ms-excel.sheet.macroenabled.12",
|
|
313
|
+
),
|
|
314
|
+
(
|
|
315
|
+
b"application/vnd.ms-powerpoint.presentation.macroenabled.main+xml",
|
|
316
|
+
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
|
|
317
|
+
),
|
|
318
|
+
(
|
|
319
|
+
b"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
|
|
320
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
321
|
+
),
|
|
322
|
+
(
|
|
323
|
+
b"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
|
|
324
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
325
|
+
),
|
|
326
|
+
(
|
|
327
|
+
b"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml",
|
|
328
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _sniff_zip(head: bytes, file: InspectedFile) -> str | None:
|
|
334
|
+
if head[:4] not in _ZIP_SIGNATURES:
|
|
335
|
+
return None
|
|
336
|
+
entries = zip_entries(file)
|
|
337
|
+
if not entries:
|
|
338
|
+
return "application/zip"
|
|
339
|
+
names = {e.filename for e in entries}
|
|
340
|
+
if entries[0].filename == "mimetype":
|
|
341
|
+
declared = (zip_read(file, "mimetype", 256) or b"").strip()
|
|
342
|
+
if declared in _ODF_MIMETYPES:
|
|
343
|
+
return _ODF_MIMETYPES[declared]
|
|
344
|
+
if "[Content_Types].xml" in names:
|
|
345
|
+
content_types = (zip_read(file, "[Content_Types].xml", 1 << 20) or b"").lower()
|
|
346
|
+
for marker, mime in _OOXML_MAIN_PARTS:
|
|
347
|
+
if marker in content_types:
|
|
348
|
+
return mime
|
|
349
|
+
if "AndroidManifest.xml" in names and "classes.dex" in names:
|
|
350
|
+
return "application/vnd.android.package-archive"
|
|
351
|
+
if "META-INF/MANIFEST.MF" in names:
|
|
352
|
+
return "application/java-archive"
|
|
353
|
+
return "application/zip"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
_HeadDetector = Callable[[bytes], "str | None"]
|
|
357
|
+
_FileDetector = Callable[[bytes, "InspectedFile"], "str | None"]
|
|
358
|
+
|
|
359
|
+
_HEAD_DETECTORS: tuple[_HeadDetector, ...] = (
|
|
360
|
+
_sniff_riff,
|
|
361
|
+
_sniff_ftyp,
|
|
362
|
+
_sniff_ebml,
|
|
363
|
+
_sniff_bmp,
|
|
364
|
+
_sniff_ico,
|
|
365
|
+
_sniff_bzip2,
|
|
366
|
+
_sniff_id3_or_mpeg_audio,
|
|
367
|
+
_sniff_cafebabe,
|
|
368
|
+
_sniff_tar,
|
|
369
|
+
)
|
|
370
|
+
_FILE_DETECTORS: tuple[_FileDetector, ...] = (_sniff_zip, _sniff_mz)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@dataclass(frozen=True, slots=True)
|
|
374
|
+
class BuiltinSniffer(Deconstructible):
|
|
375
|
+
"""Pure-Python signature sniffer covering the formats in the type registry."""
|
|
376
|
+
|
|
377
|
+
_deconstruct_module = "uploadshield.core.sniffing"
|
|
378
|
+
|
|
379
|
+
def sniff(self, file: InspectedFile) -> FileType | None:
|
|
380
|
+
head = file.head()
|
|
381
|
+
if not head:
|
|
382
|
+
return None
|
|
383
|
+
mime = self._sniff_mime(head, file)
|
|
384
|
+
return get_type(mime) if mime else None
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _sniff_mime(head: bytes, file: InspectedFile) -> str | None:
|
|
388
|
+
for prefix, mime in _PREFIXES:
|
|
389
|
+
if head.startswith(prefix):
|
|
390
|
+
return mime
|
|
391
|
+
# A UTF-16 BOM (FF FE) doubles as an MPEG audio frame sync; text wins.
|
|
392
|
+
if head.startswith((b"\xff\xfe", b"\xfe\xff")) and (text := _classify_text(head)):
|
|
393
|
+
return text
|
|
394
|
+
for file_detector in _FILE_DETECTORS:
|
|
395
|
+
if mime := file_detector(head, file):
|
|
396
|
+
return mime
|
|
397
|
+
for detector in _HEAD_DETECTORS:
|
|
398
|
+
if mime := detector(head):
|
|
399
|
+
return mime
|
|
400
|
+
return _classify_text(head)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@dataclass(frozen=True, slots=True)
|
|
404
|
+
class MagicSniffer(Deconstructible):
|
|
405
|
+
"""Identify types with libmagic (``uv add "uploadshield[magic]"``).
|
|
406
|
+
|
|
407
|
+
Best used as a fallback after :class:`BuiltinSniffer` via :class:`ChainedSniffer`.
|
|
408
|
+
Results that do not map to a registered type are treated as unidentified.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
_deconstruct_module = "uploadshield.core.sniffing"
|
|
412
|
+
head_size: int = 64 * 1024
|
|
413
|
+
|
|
414
|
+
def sniff(self, file: InspectedFile) -> FileType | None:
|
|
415
|
+
import magic
|
|
416
|
+
|
|
417
|
+
mime = magic.from_buffer(file.head(self.head_size), mime=True)
|
|
418
|
+
return get_type(mime) if isinstance(mime, str) else None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@dataclass(frozen=True, slots=True)
|
|
422
|
+
class ChainedSniffer(Deconstructible):
|
|
423
|
+
"""Try several sniffers in order; the first positive identification wins."""
|
|
424
|
+
|
|
425
|
+
_deconstruct_module = "uploadshield.core.sniffing"
|
|
426
|
+
sniffers: tuple[Sniffer, ...]
|
|
427
|
+
|
|
428
|
+
def sniff(self, file: InspectedFile) -> FileType | None:
|
|
429
|
+
for sniffer in self.sniffers:
|
|
430
|
+
if (found := sniffer.sniff(file)) is not None:
|
|
431
|
+
return found
|
|
432
|
+
return None
|