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,277 @@
1
+ """Detection of active content in PDF documents (no third-party dependency)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import re
8
+ import zlib
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ from uploadshield.core.check import Check
13
+ from uploadshield.core.types import MiB
14
+ from uploadshield.core.upload import ContentTooLargeError
15
+ from uploadshield.core.violations import Code, Violation
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterator
19
+
20
+ from uploadshield.core.upload import InspectedFile
21
+
22
+ __all__ = ["DEFAULT_PDF_KEYWORDS", "PdfActiveContent", "pdf_names"]
23
+
24
+ #: Name objects that make a PDF execute code, launch programs, carry payloads or
25
+ #: exfiltrate data. ``/OpenAction`` and ``/AA`` are deliberately absent: LaTeX and
26
+ #: most office suites emit them for harmless view settings; the scripts they would
27
+ #: trigger are caught by ``/JavaScript`` and ``/JS``.
28
+ DEFAULT_PDF_KEYWORDS = frozenset(
29
+ {
30
+ "JavaScript",
31
+ "JS",
32
+ "Launch",
33
+ "EmbeddedFile",
34
+ "EmbeddedFiles",
35
+ "RichMedia",
36
+ "XFA",
37
+ "ImportData",
38
+ "SubmitForm",
39
+ "GoToE",
40
+ }
41
+ )
42
+
43
+ _NAME = re.compile(rb"/([^\s/()<>\[\]{}%]{1,127})")
44
+ _HEX_ESCAPE = re.compile(rb"#([0-9A-Fa-f]{2})")
45
+
46
+
47
+ def pdf_names(data: bytes) -> set[str]:
48
+ """All PDF name objects in ``data`` with ``#xx`` escapes decoded (``/J#61vaScript``)."""
49
+ names: set[str] = set()
50
+ for raw in set(_NAME.findall(data)):
51
+ decoded = (
52
+ _HEX_ESCAPE.sub(lambda m: bytes([int(m.group(1), 16)]), raw) if b"#" in raw else raw
53
+ )
54
+ names.add(decoded.decode("latin-1"))
55
+ return names
56
+
57
+
58
+ class _Uncheckable(Exception): # noqa: N818 - a verdict, not an error
59
+ """An object stream that cannot be decoded, so the objects in it cannot be checked."""
60
+
61
+
62
+ _FILTER = re.compile(rb"/Filter\s*(\[[^\]]*\]|/[^\s/\[\]<>()]+)")
63
+ _PREDICTOR = re.compile(rb"/Predictor\s+(\d+)")
64
+ _FILTER_NAME = re.compile(rb"/([^\s/\[\]<>()]+)")
65
+ _LENGTH = re.compile(rb"/Length\s+(\d+)(?!\d)(?!\s+\d+\s+R)")
66
+ _WHITESPACE = b"\x00\t\n\f\r "
67
+ #: The ``stream`` keyword: after whitespace or a delimiter, before an end of line.
68
+ _KEYWORD = rb"(?:(?<=[\x00\t\n\f\r ()<>\[\]{}])|\A)stream(?:\r\n|\n|\r)"
69
+ _STREAM_KEYWORD = re.compile(_KEYWORD)
70
+ #: What the lexer stops at outside streams. ``skip`` consumes whole runs of what
71
+ #: does not change its state - ordinary bytes, comments, strings without nested
72
+ #: parentheses, hex strings - so floods of them cost no Python-level iterations.
73
+ #: Each alternative starts with a different byte, so the run never backtracks.
74
+ _TOKEN = re.compile(
75
+ rb"(?P<skip>(?:[^%(<>s]|s(?!tream[\r\n])|%[^\r\n]*|\((?:[^()\\]|\\.)*\)"
76
+ rb"|<[0-9A-Fa-f\x00\t\n\f\r ]*>)+)"
77
+ rb"|(?P<string>\()|(?P<open><<)|(?P<close>>>)|(?P<hex><)|(?P<stream>" + _KEYWORD + rb")",
78
+ re.DOTALL,
79
+ )
80
+ _STRING_TOKEN = re.compile(rb"[()\\]")
81
+
82
+
83
+ def _skip_string(data: bytes, start: int) -> int:
84
+ """The offset after the literal string opening at ``start`` (nested, escaped)."""
85
+ depth, pos = 0, start
86
+ while match := _STRING_TOKEN.search(data, pos):
87
+ token, pos = match.group(), match.end()
88
+ if token == b"\\":
89
+ pos += 1 # the escaped character, whatever it is
90
+ elif token == b"(":
91
+ depth += 1
92
+ else:
93
+ depth -= 1
94
+ if depth == 0:
95
+ return pos
96
+ raise _Uncheckable("unterminated string")
97
+
98
+
99
+ def _skip_opaque(data: bytes, kind: str | None, match: re.Match[bytes]) -> int:
100
+ """The offset after the comment, string or hex string at ``match``."""
101
+ if kind == "skip":
102
+ return match.end()
103
+ if kind == "string": # nested parentheses: counted here
104
+ return _skip_string(data, match.start())
105
+ close = data.find(b">", match.end()) # a malformed hex string
106
+ if close == -1:
107
+ raise _Uncheckable("unterminated hex string")
108
+ return close + 1
109
+
110
+
111
+ def _streams(data: bytes) -> Iterator[tuple[bytes, bytes]]:
112
+ """Every stream as ``(dictionary, raw data)``, found the way a PDF lexer reads them.
113
+
114
+ Strings, comments and nested dictionaries are skipped structurally, so text inside
115
+ a dictionary (``/Note (obj)``) or its size cannot hide its keys. Stream data is
116
+ skipped by its direct ``/Length`` when ``endstream`` follows there, as readers do.
117
+
118
+ A ``stream`` keyword the walk does not reach - inside a string, a comment or the
119
+ data of another stream - could still be what a reader finds through the cross
120
+ reference table, so it makes the file uncheckable rather than being ignored.
121
+ """
122
+ seen: set[int] = set()
123
+ depth = pos = 0
124
+ opened = -1
125
+ last: tuple[int, int] | None = None # the last top-level dictionary
126
+ while match := _TOKEN.search(data, pos):
127
+ kind, at = match.lastgroup, match.start()
128
+ if kind in ("skip", "string", "hex"):
129
+ pos = _skip_opaque(data, kind, match)
130
+ continue
131
+ pos = match.end()
132
+ if kind == "open":
133
+ opened = at if depth == 0 else opened
134
+ depth += 1
135
+ elif kind == "close":
136
+ if depth == 1:
137
+ last = (opened, pos)
138
+ depth = max(depth - 1, 0)
139
+ else: # the stream keyword
140
+ if depth or last is None or data[last[1] : at].strip(_WHITESPACE):
141
+ raise _Uncheckable("stream without a dictionary")
142
+ seen.add(at)
143
+ dictionary = _HEX_ESCAPE.sub(
144
+ lambda m: bytes([int(m.group(1), 16)]), data[last[0] : last[1]]
145
+ )
146
+ finish = _stream_end(data, dictionary, pos)
147
+ yield dictionary, data[pos:finish]
148
+ pos = min(finish + len(b"endstream"), len(data))
149
+ last = None
150
+ if depth:
151
+ raise _Uncheckable("unterminated dictionary")
152
+ if any(m.start() not in seen for m in _STREAM_KEYWORD.finditer(data)):
153
+ raise _Uncheckable("stream keyword outside the object structure")
154
+
155
+
156
+ def _stream_end(data: bytes, dictionary: bytes, begin: int) -> int:
157
+ """Where the data of a stream starting at ``begin`` ends (``endstream`` or the end)."""
158
+ if length := _LENGTH.search(dictionary):
159
+ end = begin + int(length.group(1))
160
+ after = data[end : end + 64].lstrip(_WHITESPACE)
161
+ if end <= len(data) and after.startswith(b"endstream"):
162
+ return data.find(b"endstream", end)
163
+ found = data.find(b"endstream", begin)
164
+ return found if found != -1 else len(data)
165
+
166
+
167
+ def _inflate(data: bytes, budget: int) -> bytes:
168
+ for wbits in (zlib.MAX_WBITS | 32, -zlib.MAX_WBITS): # zlib/gzip with any window; raw
169
+ try:
170
+ inflated = zlib.decompressobj(wbits).decompress(data, budget + 1)
171
+ except zlib.error:
172
+ continue
173
+ if len(inflated) > budget:
174
+ raise ContentTooLargeError(f"object streams inflate to more than {budget} bytes")
175
+ return inflated
176
+ raise _Uncheckable("FlateDecode data that cannot be inflated")
177
+
178
+
179
+ def _decode(raw: bytes, filters: list[str], budget: int) -> bytes:
180
+ data = raw
181
+ for name in filters:
182
+ if name in ("FlateDecode", "Fl"):
183
+ data = _inflate(data, budget)
184
+ elif name in ("ASCIIHexDecode", "AHx"):
185
+ digits = bytes(c for c in data.split(b">", 1)[0] if c not in b" \t\r\n\f\x00")
186
+ data = bytes.fromhex((digits + b"0" * (len(digits) % 2)).decode("latin-1"))
187
+ elif name in ("ASCII85Decode", "A85"):
188
+ data = base64.a85decode(data.strip().removeprefix(b"<~").split(b"~>", 1)[0])
189
+ else:
190
+ raise _Uncheckable(f"filter {name}")
191
+ return data
192
+
193
+
194
+ def _object_streams(data: bytes, budget: int) -> Iterator[bytes]:
195
+ """Decode every object stream, where PDFs can hide action dictionaries.
196
+
197
+ Objects live either in the file body, which is scanned as it is, or in object
198
+ streams (``/Type /ObjStm``, or any stream with ``/N`` and ``/First``, as readers do
199
+ not insist on the type). Each one is decoded completely, under one budget for the
200
+ whole file; a budget overrun or an encoding that cannot be decoded is reported, so
201
+ nothing is skipped silently.
202
+ """
203
+ for dictionary, raw in _streams(data):
204
+ names = pdf_names(dictionary)
205
+ if "ObjStm" not in names and not {"N", "First"} <= names:
206
+ continue
207
+ if (predictor := _PREDICTOR.search(dictionary)) and int(predictor.group(1)) > 1:
208
+ raise _Uncheckable("predictor")
209
+ filters_match = _FILTER.search(dictionary)
210
+ filters = (
211
+ [n.decode("latin-1") for n in _FILTER_NAME.findall(filters_match.group(1))]
212
+ if filters_match
213
+ else []
214
+ )
215
+ try:
216
+ decoded = _decode(raw, filters, budget)
217
+ except (ValueError, binascii.Error) as exc:
218
+ raise _Uncheckable(str(exc)) from exc
219
+ budget -= len(decoded)
220
+ if budget < 0:
221
+ raise ContentTooLargeError("object streams inflate beyond the budget")
222
+ yield decoded
223
+
224
+
225
+ @dataclass(frozen=True, slots=True)
226
+ class PdfActiveContent(Check):
227
+ """Refuse PDFs containing scripts, launch actions, embedded files or forms that
228
+ submit data.
229
+
230
+ Name objects are matched exactly after decoding ``#xx`` obfuscation, in the raw
231
+ file *and* inside compressed (object) streams, where attackers usually hide them.
232
+ Encrypted PDFs cannot be inspected and are refused unless ``allow_encrypted``.
233
+ """
234
+
235
+ _deconstruct_module = "uploadshield.checks"
236
+ name = "pdf"
237
+
238
+ forbidden: frozenset[str] = DEFAULT_PDF_KEYWORDS
239
+ inspect_streams: bool = True
240
+ allow_encrypted: bool = False
241
+ max_bytes: int = 200 * MiB
242
+ max_inflated_bytes: int = 64 * MiB
243
+
244
+ def applies(self, file: InspectedFile) -> bool:
245
+ return file.file_type is not None and file.file_type.mime == "application/pdf"
246
+
247
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
248
+ data = file.read(self.max_bytes)
249
+ names = pdf_names(data)
250
+ if "Encrypt" in names and not self.allow_encrypted:
251
+ yield self.violation(
252
+ Code.PDF_ACTIVE_CONTENT,
253
+ "Encrypted PDF files cannot be checked and are not allowed.",
254
+ reason="encrypted",
255
+ keywords=["Encrypt"],
256
+ )
257
+ return
258
+ # Without these names no stream can be an object stream: nothing to decode.
259
+ if self.inspect_streams and ("ObjStm" in names or {"N", "First"} <= names):
260
+ try:
261
+ for decoded in _object_streams(data, self.max_inflated_bytes):
262
+ names |= pdf_names(decoded)
263
+ except _Uncheckable as exc:
264
+ yield self.violation(
265
+ Code.PDF_ACTIVE_CONTENT,
266
+ "The PDF stores objects in a form that cannot be checked.",
267
+ reason="uncheckable",
268
+ detail=str(exc),
269
+ )
270
+ return
271
+ if hits := sorted(names & self.forbidden):
272
+ yield self.violation(
273
+ Code.PDF_ACTIVE_CONTENT,
274
+ "The PDF contains active content such as scripts or embedded files.",
275
+ reason="active_content",
276
+ keywords=hits,
277
+ )
@@ -0,0 +1,205 @@
1
+ """External malware scanners: ClamAV (clamd) and YARA signatures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import logging
7
+ import socket
8
+ import struct
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+ from urllib.parse import urlsplit
13
+
14
+ from uploadshield.core.check import Check
15
+ from uploadshield.core.types import MiB
16
+ from uploadshield.core.violations import Code, Violation
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterator
20
+
21
+ from uploadshield.core.upload import InspectedFile
22
+
23
+ __all__ = ["ClamAV", "ClamdError", "YaraRules", "clamd_ping", "clamd_scan"]
24
+
25
+ logger = logging.getLogger("uploadshield")
26
+
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # ClamAV
30
+ # --------------------------------------------------------------------------- #
31
+
32
+
33
+ class ClamdError(Exception):
34
+ """clamd could not be reached or answered with an error."""
35
+
36
+
37
+ def _connect(address: str, timeout: float) -> socket.socket:
38
+ parts = urlsplit(address)
39
+ if parts.scheme == "tcp":
40
+ if not parts.hostname or not parts.port:
41
+ raise ClamdError(f"Invalid clamd address {address!r}; use tcp://host:port")
42
+ return socket.create_connection((parts.hostname, parts.port), timeout=timeout)
43
+ if parts.scheme == "unix":
44
+ path = parts.path
45
+ elif not parts.scheme:
46
+ path = address
47
+ else:
48
+ raise ClamdError(f"Unsupported clamd address scheme {parts.scheme!r}")
49
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
50
+ sock.settimeout(timeout)
51
+ try:
52
+ sock.connect(path)
53
+ except OSError:
54
+ sock.close()
55
+ raise
56
+ return sock
57
+
58
+
59
+ def clamd_ping(address: str, *, timeout: float = 5.0) -> bool:
60
+ """Whether a clamd answers ``PING`` at ``address`` (to fail early, before scanning)."""
61
+ try:
62
+ with _connect(address, timeout) as sock:
63
+ sock.sendall(b"zPING\x00")
64
+ reply = b""
65
+ while not reply.endswith(b"\x00") and len(reply) < 64:
66
+ data = sock.recv(64)
67
+ if not data:
68
+ break
69
+ reply += data
70
+ except (OSError, ClamdError):
71
+ return False
72
+ return reply.rstrip(b"\x00").strip() == b"PONG"
73
+
74
+
75
+ def clamd_scan(file: InspectedFile, address: str, *, timeout: float, chunk_size: int) -> str | None:
76
+ """Stream ``file`` to clamd (``INSTREAM``); return the signature name or ``None``."""
77
+ with _connect(address, timeout) as sock:
78
+ sock.sendall(b"zINSTREAM\x00")
79
+ for chunk in file.iter_chunks(chunk_size):
80
+ sock.sendall(struct.pack(">I", len(chunk)) + chunk)
81
+ sock.sendall(struct.pack(">I", 0))
82
+ reply = b""
83
+ while not reply.endswith(b"\x00"):
84
+ data = sock.recv(4096)
85
+ if not data:
86
+ break
87
+ reply += data
88
+ answer = reply.rstrip(b"\x00").decode("utf-8", "replace").strip()
89
+ # "stream: OK" | "stream: Eicar-Signature FOUND" | "... ERROR"
90
+ status = answer.split(":", 1)[-1].strip()
91
+ if status == "OK":
92
+ return None
93
+ if status.endswith(" FOUND"):
94
+ return status.removesuffix(" FOUND").strip()
95
+ raise ClamdError(answer or "empty reply from clamd")
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class ClamAV(Check):
100
+ """Scan the file with a running ``clamd`` daemon.
101
+
102
+ ``address`` is ``unix:///path/to/clamd.sock`` or ``tcp://host:3310``. If the
103
+ daemon is unreachable the file is refused (``fail_open=False``), because silently
104
+ skipping the scan would turn an outage into a bypass.
105
+ """
106
+
107
+ _deconstruct_module = "uploadshield.checks"
108
+ name = "clamav"
109
+
110
+ address: str = "unix:///var/run/clamav/clamd.ctl"
111
+ timeout: float = 30.0
112
+ fail_open: bool = False
113
+ chunk_size: int = 256 * 1024
114
+
115
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
116
+ try:
117
+ signature = clamd_scan(
118
+ file, self.address, timeout=self.timeout, chunk_size=self.chunk_size
119
+ )
120
+ except (OSError, ClamdError) as exc:
121
+ # An outage is expected operational noise: log without a traceback.
122
+ logger.error("ClamAV scan failed for %r: %s", file.name, exc) # noqa: TRY400
123
+ if not self.fail_open:
124
+ yield self.violation(
125
+ Code.SCANNER_UNAVAILABLE,
126
+ "The file could not be scanned for malware.",
127
+ error=str(exc),
128
+ )
129
+ return
130
+ if signature:
131
+ yield self.violation(
132
+ Code.MALWARE_DETECTED, "The file was identified as malware.", signature=signature
133
+ )
134
+
135
+
136
+ # --------------------------------------------------------------------------- #
137
+ # YARA
138
+ # --------------------------------------------------------------------------- #
139
+
140
+ _RULE_SUFFIXES = (".yar", ".yara")
141
+
142
+
143
+ def _rule_files(paths: tuple[str, ...]) -> dict[str, str]:
144
+ files: dict[str, str] = {}
145
+ for entry in paths:
146
+ path = Path(entry)
147
+ candidates = (
148
+ sorted(p for p in path.iterdir() if p.suffix.lower() in _RULE_SUFFIXES)
149
+ if path.is_dir()
150
+ else [path]
151
+ )
152
+ for candidate in candidates:
153
+ namespace = candidate.stem
154
+ while namespace in files:
155
+ namespace += "_"
156
+ files[namespace] = str(candidate)
157
+ return files
158
+
159
+
160
+ @functools.cache
161
+ def _compiled(paths: tuple[str, ...], source: str | None) -> Any:
162
+ import yara
163
+
164
+ if source is not None:
165
+ return yara.compile(source=source)
166
+ files = _rule_files(paths)
167
+ if not files:
168
+ raise ValueError(f"No YARA rule files found in {paths!r}")
169
+ return yara.compile(filepaths=files)
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class YaraRules(Check):
174
+ """Match the file against YARA rules (``uv add "uploadshield[yara]"``).
175
+
176
+ ``rules`` lists rule files and/or directories (``*.yar``/``*.yara``); ``source``
177
+ accepts inline rule text. Rules are compiled once per process and cached.
178
+ """
179
+
180
+ _deconstruct_module = "uploadshield.checks"
181
+ name = "yara"
182
+ requires = ("yara",)
183
+
184
+ rules: tuple[str, ...] = ()
185
+ source: str | None = None
186
+ timeout: int = 30
187
+ max_bytes: int = 256 * MiB
188
+
189
+ def __post_init__(self) -> None:
190
+ object.__setattr__(self, "rules", tuple(str(p) for p in self.rules))
191
+ if not self.rules and self.source is None:
192
+ raise ValueError("YaraRules needs rule paths or inline source")
193
+
194
+ def compile(self) -> Any:
195
+ """Compile (or fetch the cached) rules; raises on syntax errors."""
196
+ return _compiled(self.rules, self.source)
197
+
198
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
199
+ matches = self.compile().match(data=file.read(self.max_bytes), timeout=self.timeout)
200
+ if matches:
201
+ yield self.violation(
202
+ Code.SIGNATURE_MATCH,
203
+ "The file matches a known malicious signature.",
204
+ rules=sorted({m.rule for m in matches}),
205
+ )
@@ -0,0 +1,123 @@
1
+ """XML document type definitions: the vehicle of XXE and entity-expansion bombs."""
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_head
11
+ from uploadshield.core.types import KiB
12
+ from uploadshield.core.violations import Code, Violation
13
+
14
+ from .office import OFFICE_ZIP_TYPES
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterator
18
+
19
+ from uploadshield.core.upload import InspectedFile
20
+
21
+ __all__ = ["XmlSafety"]
22
+
23
+ #: ZIP-based document formats whose parts are XML that servers parse (OOXML, ODF, EPUB).
24
+ XML_DOCUMENT_TYPES = OFFICE_ZIP_TYPES | {"application/epub+zip"}
25
+ _XML_TYPES = frozenset({"application/xml"})
26
+ _XML_PARTS = (
27
+ ".xml", ".rels", ".vml", ".xhtml", ".htm", ".html", ".opf", ".ncx", ".svg", ".rdf", ".xsl",
28
+ ) # fmt: skip
29
+ _PROLOG_NOISE = re.compile(r"\s+|<\?.*?\?>|<!--.*?-->", re.DOTALL)
30
+ _ROOT_ELEMENT = re.compile(r"<[A-Za-z_:À-\U000effff]")
31
+ _BOMS = (
32
+ (b"\xef\xbb\xbf", "utf-8"),
33
+ (b"\x00\x00\xfe\xff", "utf-32-be"),
34
+ (b"\xff\xfe\x00\x00", "utf-32-le"),
35
+ (b"\xfe\xff", "utf-16-be"),
36
+ (b"\xff\xfe", "utf-16-le"),
37
+ )
38
+ #: Encodings recognisable from "<?" without a byte order mark (XML 1.0, appendix F).
39
+ _UNMARKED = (
40
+ (b"\x00\x00\x00<", "utf-32-be"),
41
+ (b"<\x00\x00\x00", "utf-32-le"),
42
+ (b"\x00<\x00?", "utf-16-be"),
43
+ (b"<\x00?\x00", "utf-16-le"),
44
+ )
45
+
46
+
47
+ def _decode(head: bytes) -> str:
48
+ for marker, encoding in _BOMS:
49
+ if head.startswith(marker):
50
+ return head[len(marker) :].decode(encoding, "ignore")
51
+ for marker, encoding in _UNMARKED:
52
+ if head.startswith(marker):
53
+ return head.decode(encoding, "ignore")
54
+ return head.decode("utf-8", "ignore")
55
+
56
+
57
+ def declares_dtd(head: bytes, *, complete: bool) -> bool | None:
58
+ """Whether the XML prolog in ``head`` declares a DTD; ``None`` if it cannot tell.
59
+
60
+ A DTD (and with it every entity) can only appear in the prolog, before the root
61
+ element, so the first bytes decide. ``complete`` says whether ``head`` is the whole
62
+ document: a prolog that runs past an incomplete head cannot be judged.
63
+ """
64
+ text = _decode(head)
65
+ position = 0
66
+ while match := _PROLOG_NOISE.match(text, position):
67
+ position = match.end()
68
+ rest = text[position : position + 9].lower()
69
+ if rest.startswith(("<!doctype", "<!entity")):
70
+ return True
71
+ if _ROOT_ELEMENT.match(text, position):
72
+ return False
73
+ return False if complete else None
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class XmlSafety(Check):
78
+ """Refuse XML that declares a document type (DTD) - in Office documents, ODF and
79
+ EPUB, and in XML uploads a policy explicitly allows.
80
+
81
+ DTDs are how external entities (XXE: server-side file disclosure and request
82
+ forgery when the document is parsed) and entity-expansion bombs ("billion
83
+ laughs") get in. Office, LibreOffice and e-book readers never write them, so their
84
+ presence is a reliable sign of an attack on whatever parses the upload. SVG is
85
+ covered by :class:`~uploadshield.checks.SvgActiveContent`.
86
+ """
87
+
88
+ _deconstruct_module = "uploadshield.checks"
89
+ name = "xml"
90
+
91
+ head_bytes: int = 64 * KiB
92
+
93
+ def applies(self, file: InspectedFile) -> bool:
94
+ file_type = file.file_type
95
+ return file_type is not None and file_type.mime in (XML_DOCUMENT_TYPES | _XML_TYPES)
96
+
97
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
98
+ file_type = file.file_type
99
+ if file_type is not None and file_type.mime in _XML_TYPES:
100
+ head = file.head(self.head_bytes)
101
+ verdicts = {file.name: declares_dtd(head, complete=file.size <= len(head))}
102
+ else:
103
+ verdicts = {}
104
+ for entry in zip_entries(file) or []:
105
+ if entry.is_dir() or not entry.filename.lower().endswith(_XML_PARTS):
106
+ continue
107
+ head = zip_head(file, entry.filename, self.head_bytes)
108
+ if head is not None: # unreadable members are ArchiveSafety's business
109
+ verdicts[entry.filename] = declares_dtd(
110
+ head, complete=entry.file_size <= len(head)
111
+ )
112
+ if dtds := [part for part, verdict in verdicts.items() if verdict is True]:
113
+ yield self.violation(
114
+ Code.XML_DTD,
115
+ "The document contains an XML document type definition, which is not allowed.",
116
+ parts=dtds[:10],
117
+ )
118
+ elif unclear := [part for part, verdict in verdicts.items() if verdict is None]:
119
+ yield self.violation(
120
+ Code.XML_DTD,
121
+ "The document's XML could not be checked for a document type definition.",
122
+ parts=unclear[:10],
123
+ )