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,129 @@
|
|
|
1
|
+
"""A machine-learning second opinion from Google Magika (``uv add "uploadshield[magika]"``).
|
|
2
|
+
|
|
3
|
+
Magika classifies 200+ formats, including the text and source-code formats that have
|
|
4
|
+
no magic number (it tells CSV from Python, PowerShell or batch). It is used here as a
|
|
5
|
+
**veto only**: it can refuse a file the deterministic checks accepted, never accept one
|
|
6
|
+
they refused. The reasons:
|
|
7
|
+
|
|
8
|
+
* it is a probabilistic classifier with no claims about adversarial robustness;
|
|
9
|
+
* it only looks at the first and last KiB of a file, so whoever writes those bytes
|
|
10
|
+
steers its verdict;
|
|
11
|
+
* a model upgrade changes its answers.
|
|
12
|
+
|
|
13
|
+
A bad verdict can therefore cost availability (a false positive) but never safety.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from uploadshield.core.check import Check
|
|
23
|
+
from uploadshield.core.violations import Code, Violation
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Iterator
|
|
27
|
+
|
|
28
|
+
from uploadshield.core.upload import InspectedFile
|
|
29
|
+
|
|
30
|
+
__all__ = ["DEFAULT_MAGIKA_FORBIDDEN", "MAGIKA_EQUIVALENTS", "MagikaCheck"]
|
|
31
|
+
|
|
32
|
+
#: Magika labels for content that executes or renders when opened or served.
|
|
33
|
+
DEFAULT_MAGIKA_FORBIDDEN = frozenset({
|
|
34
|
+
# executables, installers, bytecode, shortcuts
|
|
35
|
+
"pebin", "elf", "macho", "coff", "msi", "lnk", "dex", "apk", "jar", "javabytecode",
|
|
36
|
+
"pythonbytecode", "wasm", "chm", "swf", "crx", "xpi", "internetshortcut", "winregistry",
|
|
37
|
+
# scripts
|
|
38
|
+
"shell", "batch", "powershell", "vba", "autohotkey", "autoit", "javascript",
|
|
39
|
+
"typescript", "coffeescript", "python", "perl", "ruby", "php", "asp", "lua", "tcl",
|
|
40
|
+
# active markup and server configuration
|
|
41
|
+
"html", "svg", "mht", "htaccess",
|
|
42
|
+
}) # fmt: skip
|
|
43
|
+
|
|
44
|
+
#: Labels that correspond to a registered type: if a policy explicitly allows that
|
|
45
|
+
#: type (e.g. ``text/html``), Magika agreeing with it is not a reason to refuse.
|
|
46
|
+
MAGIKA_EQUIVALENTS: dict[str, str] = {
|
|
47
|
+
"html": "text/html",
|
|
48
|
+
"svg": "image/svg+xml",
|
|
49
|
+
"php": "application/x-httpd-php",
|
|
50
|
+
"shell": "application/x-sh",
|
|
51
|
+
"pebin": "application/vnd.microsoft.portable-executable",
|
|
52
|
+
"elf": "application/x-elf",
|
|
53
|
+
"macho": "application/x-mach-binary",
|
|
54
|
+
"jar": "application/java-archive",
|
|
55
|
+
"apk": "application/vnd.android.package-archive",
|
|
56
|
+
"wasm": "application/wasm",
|
|
57
|
+
"javabytecode": "application/java-vm",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_models: dict[str, Any] = {}
|
|
61
|
+
_models_lock = threading.Lock()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _model(prediction_mode: str) -> Any:
|
|
65
|
+
"""One ``Magika`` instance per process and mode, created on first use."""
|
|
66
|
+
if (model := _models.get(prediction_mode)) is None:
|
|
67
|
+
with _models_lock:
|
|
68
|
+
if (model := _models.get(prediction_mode)) is None:
|
|
69
|
+
from magika import Magika, PredictionMode
|
|
70
|
+
|
|
71
|
+
model = Magika(prediction_mode=PredictionMode(prediction_mode))
|
|
72
|
+
_models[prediction_mode] = model
|
|
73
|
+
return model
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class MagikaCheck(Check):
|
|
78
|
+
"""Refuse files that Magika confidently recognises as code, executables or markup.
|
|
79
|
+
|
|
80
|
+
Typical catch: a ``.csv`` or ``.txt`` that is really a PowerShell, batch or Python
|
|
81
|
+
script; deterministic sniffing can only prove it is text. Only labels in
|
|
82
|
+
``forbidden`` with a score of at least ``min_score`` refuse a file, and never when
|
|
83
|
+
the label matches the type the policy already accepted (see
|
|
84
|
+
:data:`MAGIKA_EQUIVALENTS`). ``prediction_mode`` is ``"high_confidence"``,
|
|
85
|
+
``"medium_confidence"`` or ``"best_guess"``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
_deconstruct_module = "uploadshield.checks"
|
|
89
|
+
name = "magika"
|
|
90
|
+
requires = ("magika",)
|
|
91
|
+
|
|
92
|
+
forbidden: frozenset[str] = DEFAULT_MAGIKA_FORBIDDEN
|
|
93
|
+
min_score: float = 0.9
|
|
94
|
+
prediction_mode: str = "high_confidence"
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
if self.prediction_mode not in ("high_confidence", "medium_confidence", "best_guess"):
|
|
98
|
+
raise ValueError(f"Unknown Magika prediction mode {self.prediction_mode!r}")
|
|
99
|
+
if not 0.0 <= self.min_score <= 1.0:
|
|
100
|
+
raise ValueError("min_score must be between 0 and 1")
|
|
101
|
+
object.__setattr__(self, "forbidden", frozenset(self.forbidden))
|
|
102
|
+
|
|
103
|
+
def applies(self, file: InspectedFile) -> bool:
|
|
104
|
+
return file.file_type is not None
|
|
105
|
+
|
|
106
|
+
def run(self, file: InspectedFile) -> Iterator[Violation]:
|
|
107
|
+
model = _model(self.prediction_mode)
|
|
108
|
+
with file.open() as fh:
|
|
109
|
+
result = model.identify_stream(fh)
|
|
110
|
+
if not result.ok:
|
|
111
|
+
raise RuntimeError(f"Magika could not analyse the file: {result.status}")
|
|
112
|
+
output = result.output
|
|
113
|
+
label = str(output.label)
|
|
114
|
+
accepted = file.file_type.mime if file.file_type else None
|
|
115
|
+
if (
|
|
116
|
+
label in self.forbidden
|
|
117
|
+
and result.score >= self.min_score
|
|
118
|
+
and MAGIKA_EQUIVALENTS.get(label) != accepted
|
|
119
|
+
):
|
|
120
|
+
yield self.violation(
|
|
121
|
+
Code.CONTENT_MISMATCH,
|
|
122
|
+
f"The file's content looks like {output.description}, which is not allowed here.",
|
|
123
|
+
magika_label=label,
|
|
124
|
+
score=round(float(result.score), 3),
|
|
125
|
+
group=str(output.group),
|
|
126
|
+
mime_type=str(output.mime_type),
|
|
127
|
+
model=str(model.get_model_name()),
|
|
128
|
+
detected_type=accepted,
|
|
129
|
+
)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Script-capable markup (SVG) and code or polyglot payloads hidden in media files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
import struct
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from uploadshield.core.check import Check
|
|
12
|
+
from uploadshield.core.types import GiB, MiB
|
|
13
|
+
from uploadshield.core.upload import ContentTooLargeError
|
|
14
|
+
from uploadshield.core.violations import Code, Violation
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
|
|
19
|
+
from uploadshield.core.upload import InspectedFile
|
|
20
|
+
|
|
21
|
+
__all__ = ["EmbeddedContent", "SvgActiveContent"]
|
|
22
|
+
|
|
23
|
+
# "<" cannot occur inside an XML attribute value, so "<[^<]*" spans a whole start tag -
|
|
24
|
+
# also when an attribute value contains ">" - and the scan stays linear.
|
|
25
|
+
_SVG_RULES: dict[str, re.Pattern[str]] = {
|
|
26
|
+
"script element": re.compile(r"<\s*(?:[a-z0-9_-]+:)?script\b"),
|
|
27
|
+
"script URI": re.compile(r"(?:java|vb)script:|data:text/html"),
|
|
28
|
+
"foreignObject": re.compile(r"<\s*(?:[a-z0-9_-]+:)?foreignobject\b"),
|
|
29
|
+
"embedded document": re.compile(r"<\s*(?:iframe|embed|object|frame)\b"),
|
|
30
|
+
"entity declaration": re.compile(r"<!entity|<!doctype[^<]*\["),
|
|
31
|
+
"XSLT stylesheet": re.compile(r"<\?xml-stylesheet"),
|
|
32
|
+
"external resource": re.compile(r"href\s*=\s*[\"']?\s*(?:https?:)?//"),
|
|
33
|
+
"href animation": re.compile(
|
|
34
|
+
r"<\s*(?:set|animate)\b[^<]*attributename\s*=\s*[\"']?(?:xlink:)?href"
|
|
35
|
+
),
|
|
36
|
+
}
|
|
37
|
+
#: An ``on...=`` attribute inside a start tag. Attribute values are skipped as quoted
|
|
38
|
+
#: strings, so a ">" inside a value does not end the tag, while text after the tag is
|
|
39
|
+
#: never mistaken for an attribute. Matched on the text *before* character references
|
|
40
|
+
#: are decoded: "">" inside a value must not look like the end of the tag. Every
|
|
41
|
+
#: part stops at "<", ">" or a quote, so the scan stays linear.
|
|
42
|
+
_EVENT_HANDLER = re.compile(
|
|
43
|
+
r"<[a-z][^\s/<>]*"
|
|
44
|
+
r"(?:[\s/]+[^\s/<>=\"']+(?:\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s<>\"']*))?)*?"
|
|
45
|
+
r"(?:[\s/]+|(?<=[\"']))on[a-z]+\s*="
|
|
46
|
+
)
|
|
47
|
+
_URL_INVISIBLES = re.compile(r"[\t\n\r\x00]")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _decode_markup(data: bytes) -> str:
|
|
51
|
+
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
52
|
+
return data.decode("utf-16", "replace").lower()
|
|
53
|
+
return data.decode("utf-8", "replace").lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _normalize_markup(text: str) -> str:
|
|
57
|
+
# Browsers decode character references and ignore tabs/newlines inside URLs, so
|
|
58
|
+
# "java	script:" must be seen as "javascript:".
|
|
59
|
+
return _URL_INVISIBLES.sub("", html.unescape(text)).lower()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class SvgActiveContent(Check):
|
|
64
|
+
"""Refuse SVG images that can run script or load external resources.
|
|
65
|
+
|
|
66
|
+
SVG is XML that browsers execute like HTML; when served from your origin it is a
|
|
67
|
+
stored-XSS vector. Prefer rasterising user SVGs; if you accept them, this check
|
|
68
|
+
rejects scripts, event handlers, ``javascript:`` URIs, ``foreignObject``,
|
|
69
|
+
entities (XXE), XSLT and external references.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
_deconstruct_module = "uploadshield.checks"
|
|
73
|
+
name = "svg"
|
|
74
|
+
|
|
75
|
+
max_bytes: int = 10 * MiB
|
|
76
|
+
|
|
77
|
+
def applies(self, file: InspectedFile) -> bool:
|
|
78
|
+
return file.file_type is not None and file.file_type.mime == "image/svg+xml"
|
|
79
|
+
|
|
80
|
+
def run(self, file: InspectedFile) -> Iterator[Violation]:
|
|
81
|
+
raw = _decode_markup(file.read(self.max_bytes))
|
|
82
|
+
text = _normalize_markup(raw)
|
|
83
|
+
found = [label for label, rule in _SVG_RULES.items() if rule.search(text)]
|
|
84
|
+
if _EVENT_HANDLER.search(raw):
|
|
85
|
+
found.insert(0, "event handler attribute")
|
|
86
|
+
if found:
|
|
87
|
+
yield self.violation(
|
|
88
|
+
Code.SVG_ACTIVE_CONTENT, "The SVG image contains active content.", found=found
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Markers specific enough that they do not occur by chance in compressed media data
|
|
93
|
+
# (a 3-byte "<?=" alone would, about once per 20 MB): PHP open and short-echo tags,
|
|
94
|
+
# HTML scripts, JSP/ASP directives, server-side includes and PHAR stubs (polyglot
|
|
95
|
+
# images that PHP deserializes through phar://).
|
|
96
|
+
_CODE_MARKERS = re.compile(
|
|
97
|
+
rb"<\?php\s|<\?=\s{0,3}(?:[$`]|[a-z_]\w{0,30}\s*\()|<script[\s>]"
|
|
98
|
+
rb"|<%@\s*(?:page|language|import)\b|<jsp:"
|
|
99
|
+
rb"|<!--\s*#\s*(?:exec|include|echo|config|set|printenv)\b"
|
|
100
|
+
rb"|__halt_compiler\s*\(\s*\)",
|
|
101
|
+
re.IGNORECASE,
|
|
102
|
+
)
|
|
103
|
+
#: What a PHP ``include`` runs from *any* file, text included: open and short-echo tags.
|
|
104
|
+
_INCLUDABLE_CODE = re.compile(rb"<\?php\s|<\?=\s{0,3}(?:[$`]|[a-z_]\w{0,30}\s*\()", re.IGNORECASE)
|
|
105
|
+
_OVERLAP = 64
|
|
106
|
+
_EOCD = b"PK\x05\x06"
|
|
107
|
+
_CENTRAL_DIRECTORY = b"PK\x01\x02"
|
|
108
|
+
_EOCD_SEARCH_WINDOW = 65_557 # 22-byte record + maximum comment length
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _embedded_zip_offset(tail: bytes) -> int | None:
|
|
112
|
+
"""Locate a *structurally valid* ZIP end record (not just its 4 magic bytes)."""
|
|
113
|
+
pos = tail.rfind(_EOCD)
|
|
114
|
+
while pos != -1:
|
|
115
|
+
if pos + 22 <= len(tail):
|
|
116
|
+
disk, cd_disk, on_disk, total, cd_size = struct.unpack_from("<HHHHI", tail, pos + 4)
|
|
117
|
+
cd_start = pos - cd_size
|
|
118
|
+
if (
|
|
119
|
+
disk == 0
|
|
120
|
+
and cd_disk == 0
|
|
121
|
+
and on_disk == total
|
|
122
|
+
and total > 0
|
|
123
|
+
and cd_start >= 0
|
|
124
|
+
and tail[cd_start : cd_start + 4] == _CENTRAL_DIRECTORY
|
|
125
|
+
):
|
|
126
|
+
return pos
|
|
127
|
+
pos = tail.rfind(_EOCD, 0, pos)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class EmbeddedContent(Check):
|
|
133
|
+
"""Refuse files that smuggle server-side code or a second file format.
|
|
134
|
+
|
|
135
|
+
In media it catches the classic "PHP in EXIF comment" image, HTML/script payloads
|
|
136
|
+
and polyglots: an image with a ZIP appended (GIFAR), or a PDF header hidden near the
|
|
137
|
+
start where PDF readers still find it. In text files (``scan_text``) it refuses PHP
|
|
138
|
+
tags anywhere, because PHP's ``include`` runs them from a ``.txt`` or ``.csv`` too.
|
|
139
|
+
Files larger than ``max_scan_bytes`` are refused rather than scanned in part.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
_deconstruct_module = "uploadshield.checks"
|
|
143
|
+
name = "embedded"
|
|
144
|
+
|
|
145
|
+
categories: frozenset[str] = frozenset({"image", "audio", "video"})
|
|
146
|
+
scan_text: bool = True
|
|
147
|
+
max_scan_bytes: int = 1 * GiB
|
|
148
|
+
|
|
149
|
+
def applies(self, file: InspectedFile) -> bool:
|
|
150
|
+
file_type = file.file_type
|
|
151
|
+
if file_type is None:
|
|
152
|
+
return False
|
|
153
|
+
if file_type.is_text:
|
|
154
|
+
return self.scan_text
|
|
155
|
+
return file_type.category in self.categories
|
|
156
|
+
|
|
157
|
+
def run(self, file: InspectedFile) -> Iterator[Violation]:
|
|
158
|
+
if file.size > self.max_scan_bytes:
|
|
159
|
+
raise ContentTooLargeError(f"{file.size} bytes exceeds {self.max_scan_bytes}")
|
|
160
|
+
text = file.file_type is not None and file.file_type.is_text
|
|
161
|
+
if marker := self._find_code(file, _INCLUDABLE_CODE if text else _CODE_MARKERS):
|
|
162
|
+
yield self.violation(
|
|
163
|
+
Code.EMBEDDED_CODE, "The file contains embedded code.", marker=marker
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
if text:
|
|
167
|
+
return
|
|
168
|
+
if b"%PDF-" in file.head(1024):
|
|
169
|
+
yield self.violation(Code.POLYGLOT, "The file also contains a PDF document.")
|
|
170
|
+
elif _embedded_zip_offset(file.tail(_EOCD_SEARCH_WINDOW)) is not None:
|
|
171
|
+
yield self.violation(Code.POLYGLOT, "The file also contains a ZIP archive.")
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _find_code(file: InspectedFile, markers: re.Pattern[bytes]) -> str | None:
|
|
175
|
+
carry = b""
|
|
176
|
+
for chunk in file.iter_chunks():
|
|
177
|
+
window = carry + chunk
|
|
178
|
+
if match := markers.search(window):
|
|
179
|
+
return match.group(0).decode("latin-1").strip()
|
|
180
|
+
carry = window[-_OVERLAP:]
|
|
181
|
+
return None
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Macros and remote references in Office documents (OOXML, ODF, legacy OLE2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from uploadshield.core.check import Check
|
|
10
|
+
from uploadshield.core.containers import zip_entries, zip_member, zip_read, zip_search
|
|
11
|
+
from uploadshield.core.types import MiB
|
|
12
|
+
from uploadshield.core.upload import ContentTooLargeError
|
|
13
|
+
from uploadshield.core.violations import Code, Violation
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
|
|
18
|
+
from uploadshield.core.upload import InspectedFile
|
|
19
|
+
|
|
20
|
+
__all__ = ["OFFICE_ZIP_TYPES", "OfficeMacros"]
|
|
21
|
+
|
|
22
|
+
OFFICE_ZIP_TYPES = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
25
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
26
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
27
|
+
"application/vnd.ms-word.document.macroenabled.12",
|
|
28
|
+
"application/vnd.ms-excel.sheet.macroenabled.12",
|
|
29
|
+
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
|
|
30
|
+
"application/vnd.oasis.opendocument.text",
|
|
31
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
32
|
+
"application/vnd.oasis.opendocument.presentation",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
#: Relationship types that make Office fetch and run or embed remote content
|
|
37
|
+
#: ("remote template injection"). Plain hyperlinks are external too but harmless.
|
|
38
|
+
_REMOTE_RELATIONSHIPS = ("attachedtemplate", "oleobject", "frame", "subdocument")
|
|
39
|
+
# Every pattern stops at the next "<" (or cell delimiter), so scanning stays linear
|
|
40
|
+
# whatever the input: no quantifier can run across a tag boundary and backtrack.
|
|
41
|
+
_RELATIONSHIP = re.compile(rb"<relationship\b[^<>]*>", re.IGNORECASE)
|
|
42
|
+
_DDE_FIELD = re.compile(
|
|
43
|
+
rb"(?:instrtext[^<>]*>|instr=\")[ \t\r\n]*\"?[ \t\r\n]*dde(?:auto)?\b", re.IGNORECASE
|
|
44
|
+
)
|
|
45
|
+
#: Excel dynamic data exchange: ``=cmd|' /C calc'!A0`` runs a program when the sheet
|
|
46
|
+
#: opens. "|" is no Excel operator, so ``application|topic!item`` is a DDE reference.
|
|
47
|
+
_DDE_LINK = re.compile(rb"<ddeLink\b", re.IGNORECASE)
|
|
48
|
+
_DDE_FORMULA = re.compile(rb"<f\b[^<>]*>[^<|]*\|[^<]*!", re.IGNORECASE)
|
|
49
|
+
_DDE_CELL = re.compile(rb"(?:^|[,;\t\r\n])[ \t]*\"?[=+\-@][^,;\t\r\n|]*\|[^,;\t\r\n]*!")
|
|
50
|
+
_CSV_OVERLAP = 4096
|
|
51
|
+
_VBA_PROJECT_UTF16 = "_VBA_PROJECT".encode("utf-16-le")
|
|
52
|
+
_OLE_SCAN_CHUNK = 4 * MiB
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_macro_member(name: str) -> bool:
|
|
56
|
+
lowered = name.lower()
|
|
57
|
+
return (
|
|
58
|
+
lowered.endswith(("vbaproject.bin", "vbadata.xml"))
|
|
59
|
+
or "/activex/" in f"/{lowered}"
|
|
60
|
+
or lowered.startswith(("basic/", "scripts/")) # OpenDocument macros
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class OfficeMacros(Check):
|
|
66
|
+
"""Refuse Office documents with VBA macros, ActiveX controls, DDE (Word fields, Excel
|
|
67
|
+
links and formulas, and formulas in CSV files, which open in Excel) or remote
|
|
68
|
+
templates.
|
|
69
|
+
|
|
70
|
+
For legacy OLE2 files (``.doc``/``.xls``) the presence of a ``_VBA_PROJECT``
|
|
71
|
+
stream is detected from the directory entries; XLM (Excel 4.0) macros in OLE2
|
|
72
|
+
files are not detected - use :class:`~uploadshield.checks.YaraRules` or
|
|
73
|
+
:class:`~uploadshield.checks.ClamAV` if you must accept those formats.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
_deconstruct_module = "uploadshield.checks"
|
|
77
|
+
name = "office"
|
|
78
|
+
|
|
79
|
+
block_external_references: bool = True
|
|
80
|
+
max_part_bytes: int = 16 * MiB
|
|
81
|
+
|
|
82
|
+
def applies(self, file: InspectedFile) -> bool:
|
|
83
|
+
file_type = file.file_type
|
|
84
|
+
return file_type is not None and (
|
|
85
|
+
file_type.mime in OFFICE_ZIP_TYPES
|
|
86
|
+
or file_type.container == "ole"
|
|
87
|
+
or file_type.mime == "text/csv"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def run(self, file: InspectedFile) -> Iterator[Violation]:
|
|
91
|
+
file_type = file.file_type
|
|
92
|
+
if file_type is not None and file_type.container == "ole":
|
|
93
|
+
yield from self._run_ole(file)
|
|
94
|
+
elif file_type is not None and file_type.mime == "text/csv":
|
|
95
|
+
yield from self._run_csv(file)
|
|
96
|
+
else:
|
|
97
|
+
yield from self._run_zip(file)
|
|
98
|
+
|
|
99
|
+
def _run_csv(self, file: InspectedFile) -> Iterator[Violation]:
|
|
100
|
+
"""CSV opens in Excel, which evaluates DDE formulas in cells."""
|
|
101
|
+
carry = b""
|
|
102
|
+
for chunk in file.iter_chunks():
|
|
103
|
+
window = carry + chunk
|
|
104
|
+
if _DDE_CELL.search(window):
|
|
105
|
+
yield self._dde()
|
|
106
|
+
return
|
|
107
|
+
carry = window[-_CSV_OVERLAP:]
|
|
108
|
+
|
|
109
|
+
def _run_ole(self, file: InspectedFile) -> Iterator[Violation]:
|
|
110
|
+
carry = b""
|
|
111
|
+
for chunk in file.iter_chunks(_OLE_SCAN_CHUNK):
|
|
112
|
+
if _VBA_PROJECT_UTF16 in carry + chunk:
|
|
113
|
+
yield self.violation(Code.OFFICE_MACROS, "Documents with macros are not allowed.")
|
|
114
|
+
return
|
|
115
|
+
carry = chunk[-len(_VBA_PROJECT_UTF16) :]
|
|
116
|
+
|
|
117
|
+
def _run_zip(self, file: InspectedFile) -> Iterator[Violation]:
|
|
118
|
+
entries = zip_entries(file) or []
|
|
119
|
+
names = [e.filename for e in entries]
|
|
120
|
+
if macros := [n for n in names if _is_macro_member(n)]:
|
|
121
|
+
yield self.violation(
|
|
122
|
+
Code.OFFICE_MACROS, "Documents with macros are not allowed.", parts=macros[:10]
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
# Fields run from every part Word renders: body, headers, footers, notes, ...
|
|
126
|
+
word_parts = [n for n in names if n.startswith("word/") and n.endswith(".xml")]
|
|
127
|
+
for part in word_parts:
|
|
128
|
+
if zip_search(file, part, _DDE_FIELD):
|
|
129
|
+
yield self.violation(
|
|
130
|
+
Code.OFFICE_MACROS, "Documents with DDE fields are not allowed.", parts=[part]
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
spreadsheet = [
|
|
134
|
+
n
|
|
135
|
+
for n in names
|
|
136
|
+
if n.startswith(("xl/externalLinks/", "xl/worksheets/")) and n.endswith(".xml")
|
|
137
|
+
]
|
|
138
|
+
for part in spreadsheet:
|
|
139
|
+
pattern = _DDE_LINK if part.startswith("xl/externalLinks/") else _DDE_FORMULA
|
|
140
|
+
if zip_search(file, part, pattern):
|
|
141
|
+
yield self._dde(parts=[part])
|
|
142
|
+
return
|
|
143
|
+
if self.block_external_references and (targets := self._remote_targets(file, names)):
|
|
144
|
+
yield self.violation(
|
|
145
|
+
Code.OFFICE_EXTERNAL_REFERENCE,
|
|
146
|
+
"The document loads content from external locations.",
|
|
147
|
+
targets=targets[:10],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _dde(self, **details: object) -> Violation:
|
|
151
|
+
return self.violation(
|
|
152
|
+
Code.OFFICE_MACROS,
|
|
153
|
+
"Spreadsheets with DDE links or formulas are not allowed.",
|
|
154
|
+
**details,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _remote_targets(self, file: InspectedFile, names: list[str]) -> list[str]:
|
|
158
|
+
targets: list[str] = []
|
|
159
|
+
for rels in (n for n in names if n.endswith(".rels")):
|
|
160
|
+
info = zip_member(file, rels)
|
|
161
|
+
if info is not None and info.file_size > self.max_part_bytes:
|
|
162
|
+
# Unreadable means unchecked: refuse rather than skip (fails closed).
|
|
163
|
+
raise ContentTooLargeError(f"{rels} exceeds {self.max_part_bytes} bytes")
|
|
164
|
+
xml = zip_read(file, rels, self.max_part_bytes) or b""
|
|
165
|
+
for element in _RELATIONSHIP.findall(xml):
|
|
166
|
+
lowered = element.lower()
|
|
167
|
+
if b'targetmode="external"' not in lowered:
|
|
168
|
+
continue
|
|
169
|
+
type_match = re.search(rb'type="([^"]*)"', lowered)
|
|
170
|
+
rel_type = type_match.group(1).decode("latin-1") if type_match else ""
|
|
171
|
+
if rel_type.rsplit("/", 1)[-1] in _REMOTE_RELATIONSHIPS:
|
|
172
|
+
target = re.search(rb'target="([^"]*)"', element, re.IGNORECASE)
|
|
173
|
+
targets.append(target.group(1).decode("latin-1") if target else rels)
|
|
174
|
+
return targets
|