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,322 @@
1
+ """Validate every uploaded file while Django parses the request body.
2
+
3
+ Enable it as the only upload handler::
4
+
5
+ FILE_UPLOAD_HANDLERS = ["uploadshield.django.handlers.ValidatingUploadHandler"]
6
+
7
+ Every parser stores files through upload handlers: Django's ``request.POST``/
8
+ ``request.FILES`` (views, forms, the admin) and DRF's ``MultiPartParser`` and
9
+ ``FileUploadParser`` (POST, PUT, PATCH). This one setting therefore covers them all,
10
+ and it only does work when something actually parses a body. For each file:
11
+
12
+ 1. ``new_file``: the name is checked before a single byte is stored.
13
+ 2. ``receive_data_chunk``: size limits are enforced as data arrives, and as soon as
14
+ the first 8 KiB are in, the sniffed type is checked against the allow-list. A
15
+ refused file is never stored in full.
16
+ 3. ``file_complete``: the complete policy runs (format-specific checks, sanitizers)
17
+ and the application receives the sanitized file.
18
+
19
+ A refused upload raises :class:`~uploadshield.django.exceptions.UploadRejected`
20
+ (Django answers 400 via ``handler400``), unless the view reports errors inline
21
+ (``on_reject="field"``) or the mode is ``report``. Incoming data is stored by the
22
+ handlers in ``UPLOADSHIELD["UPLOAD_HANDLERS"]`` (Django's defaults).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ from typing import IO, TYPE_CHECKING, Any
29
+
30
+ from django.core.exceptions import ImproperlyConfigured
31
+ from django.core.files.uploadhandler import FileUploadHandler, StopFutureHandlers, load_handler
32
+
33
+ from uploadshield.core.result import ValidationResult
34
+ from uploadshield.core.types import format_size
35
+ from uploadshield.core.upload import HEAD_SIZE
36
+ from uploadshield.core.violations import Code, Violation
37
+ from uploadshield.engine import preflight
38
+
39
+ from .conf import get_config
40
+ from .exceptions import UploadRejected
41
+ from .service import (
42
+ FileOutcome,
43
+ RejectedUpload,
44
+ UploadValidation,
45
+ ViewSpec,
46
+ accept,
47
+ finish,
48
+ spec_for_request,
49
+ validate_file,
50
+ )
51
+
52
+ if TYPE_CHECKING:
53
+ from collections.abc import Sequence
54
+
55
+ from django.core.files.uploadedfile import UploadedFile
56
+ from django.http import HttpRequest
57
+
58
+ from uploadshield.policy import Policy
59
+
60
+ __all__ = ["ValidatingUploadHandler"]
61
+
62
+
63
+ def _resolve(request: HttpRequest) -> Any:
64
+ from django.urls import Resolver404, resolve
65
+
66
+ try:
67
+ return resolve(request.path_info, getattr(request, "urlconf", None))
68
+ except Resolver404:
69
+ return None
70
+
71
+
72
+ class ValidatingUploadHandler(FileUploadHandler):
73
+ """Django instantiates it from ``FILE_UPLOAD_HANDLERS`` with the request only.
74
+
75
+ ``inner`` (the handlers that store the data) and ``spec`` (the policy) are for
76
+ :func:`~uploadshield.django.validate_uploads`, which installs one per request when
77
+ the handler is not configured globally.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ request: HttpRequest | None = None,
83
+ *,
84
+ inner: Sequence[FileUploadHandler] | None = None,
85
+ spec: ViewSpec | None = None,
86
+ ) -> None:
87
+ super().__init__(request)
88
+ self.config = get_config()
89
+ if inner is None:
90
+ inner = [load_handler(path, request) for path in self.config.upload_handlers]
91
+ self.inner = list(inner)
92
+ if sizes := [h.chunk_size for h in self.inner if h.chunk_size]:
93
+ self.chunk_size = min(sizes)
94
+ self._spec: ViewSpec | None = spec
95
+ self.outcomes: list[FileOutcome] = []
96
+ self._per_field: dict[str, int] = {}
97
+ self._start_file(None)
98
+
99
+ # -- configuration for this request ------------------------------------------------
100
+
101
+ @property
102
+ def spec(self) -> ViewSpec:
103
+ if self._spec is None:
104
+ if self.request is None:
105
+ self._spec = ViewSpec(policy=self.config.default_policy)
106
+ else:
107
+ # Parsed before URL routing (e.g. from MIDDLEWARE): route it ourselves, so
108
+ # the view's decorator and VIEW_POLICIES still decide.
109
+ match = getattr(self.request, "resolver_match", None) or _resolve(self.request)
110
+ view = match.func if match is not None else None
111
+ self._spec = spec_for_request(self.request, view, self.config, match=match)
112
+ return self._spec
113
+
114
+ def use_spec(self, spec: ViewSpec) -> None:
115
+ """Validate with ``spec``: set by ``@validate_uploads`` before the body is parsed,
116
+ also where routing cannot see the decorator (DRF ``@api_view``, ``method_decorator``)."""
117
+ self._spec = spec
118
+
119
+ @property
120
+ def mode(self) -> str:
121
+ return self.spec.effective_mode(self.config)
122
+
123
+ @property
124
+ def inline(self) -> bool:
125
+ return self.mode == "enforce" and self.spec.inline_errors(self.config)
126
+
127
+ # -- Django's upload handler protocol ----------------------------------------------
128
+
129
+ def handle_raw_input(
130
+ self,
131
+ input_data: IO[bytes],
132
+ META: dict[str, str], # noqa: N803 - Django's parameter name
133
+ content_length: int,
134
+ boundary: str,
135
+ encoding: str | None = None,
136
+ ) -> None:
137
+ for handler in self.inner:
138
+ if handler.handle_raw_input(input_data, META, content_length, boundary, encoding):
139
+ raise ImproperlyConfigured(
140
+ f"{type(handler).__name__} parses the whole request itself, so its "
141
+ "files cannot be validated; remove it from UPLOAD_HANDLERS"
142
+ )
143
+
144
+ def new_file( # noqa: PLR0917 - Django's signature
145
+ self,
146
+ field_name: str, # None for DRF's FileUploadParser (raw single-file bodies)
147
+ file_name: str,
148
+ content_type: str,
149
+ content_length: int | None,
150
+ charset: str | None = None,
151
+ content_type_extra: dict[str, bytes] | None = None,
152
+ ) -> None:
153
+ super().new_file(
154
+ field_name, file_name, content_type, content_length, charset, content_type_extra
155
+ )
156
+ # Raw single-file bodies (DRF's FileUploadParser) arrive as request.FILES["file"].
157
+ field = field_name or "file"
158
+ policy = self.spec.policy_for(field, self.config) if self.mode != "off" else None
159
+ self._start_file(policy)
160
+ if policy is not None and self.mode == "enforce":
161
+ self._check(preflight(policy, stages={"name"}, **self._facts()))
162
+ if self.early is not None:
163
+ return # refused by name: nothing is ever stored
164
+ self.inner_started = True
165
+ for handler in self.inner:
166
+ try:
167
+ handler.new_file(
168
+ field_name,
169
+ file_name,
170
+ content_type,
171
+ content_length,
172
+ charset,
173
+ content_type_extra,
174
+ )
175
+ except StopFutureHandlers:
176
+ break
177
+
178
+ def receive_data_chunk(self, raw_data: bytes, start: int) -> None:
179
+ self.received += len(raw_data)
180
+ self._enforce_limits()
181
+ if self.early is not None:
182
+ return # refused; drop the rest of this file
183
+ if self.policy is not None and self.mode == "enforce" and not self.prechecked:
184
+ self.head += raw_data[: HEAD_SIZE - len(self.head)]
185
+ if len(self.head) >= HEAD_SIZE:
186
+ self.prechecked = True
187
+ self._check(preflight(policy=self.policy, stages={"type"}, **self._facts()))
188
+ if self.early is not None:
189
+ return
190
+ chunk: bytes | None = raw_data
191
+ for i, handler in enumerate(self.inner):
192
+ length = len(chunk)
193
+ chunk = handler.receive_data_chunk(chunk, self.counters[i])
194
+ self.counters[i] += length
195
+ if chunk is None:
196
+ break
197
+ return
198
+
199
+ def file_complete(self, file_size: int) -> UploadedFile | None:
200
+ file_obj = self._inner_complete()
201
+ field = self.field_name or "file"
202
+ if self.early is not None:
203
+ if file_obj is not None:
204
+ file_obj.close() # partial data of a refused file
205
+ return RejectedUpload(self.early, field_name=self.field_name, config=self.config)
206
+ if file_obj is None or self.policy is None:
207
+ return file_obj
208
+ result = validate_file(file_obj, self.policy, field=field)
209
+ if result is None: # pragma: no cover - handlers always return new uploads
210
+ return file_obj
211
+ outcome = FileOutcome(field, self._index(field), result, self.policy.name, file_obj)
212
+ self.outcomes.append(outcome)
213
+ if not result.ok and self.mode == "enforce" and not self.inline:
214
+ self._abort(file_obj)
215
+ delivered = accept(outcome, self.policy, self.config, inline=self.inline)
216
+ if delivered is not file_obj:
217
+ # Django only closes what ends up in request.FILES, and not at all when the
218
+ # parse fails later: hash the replaced original for the logs, close it now.
219
+ self.outcomes[-1] = dataclasses.replace(outcome, digest=outcome.sha256())
220
+ file_obj.close()
221
+ if self.field_name is None:
222
+ self._finish() # raw single-file body (DRF FileUploadParser): no upload_complete
223
+ return delivered
224
+
225
+ def upload_complete(self) -> None:
226
+ for handler in self.inner:
227
+ handler.upload_complete()
228
+ self._finish()
229
+
230
+ def upload_interrupted(self) -> None:
231
+ for handler in self.inner:
232
+ handler.upload_interrupted()
233
+
234
+ # -- internals ---------------------------------------------------------------------
235
+
236
+ def _start_file(self, policy: Policy | None) -> None:
237
+ self.policy = policy
238
+ self.received = 0
239
+ self.head = bytearray()
240
+ self.prechecked = False
241
+ self.early: ValidationResult | None = None
242
+ self.inner_started = False
243
+ self.counters = [0] * len(self.inner)
244
+
245
+ def _facts(self) -> dict[str, Any]:
246
+ return {
247
+ "name": self.file_name or "",
248
+ "head": bytes(self.head),
249
+ "size": self.received,
250
+ "declared_type": self.content_type,
251
+ "field": self.field_name,
252
+ }
253
+
254
+ def _index(self, field: str) -> int:
255
+ index = self._per_field.get(field, 0)
256
+ self._per_field[field] = index + 1
257
+ return index
258
+
259
+ def _enforce_limits(self) -> None:
260
+ hard_cap = self.config.max_upload_size
261
+ if hard_cap is not None and self.received > hard_cap:
262
+ self._refuse(self._too_large(hard_cap), force_abort=True)
263
+ policy = self.policy
264
+ if (
265
+ policy is not None
266
+ and self.mode == "enforce"
267
+ and policy.max_size is not None
268
+ and self.received > policy.max_size
269
+ and self.early is None
270
+ ):
271
+ self._check(preflight(policy, stages={"size"}, **self._facts()))
272
+
273
+ def _too_large(self, limit: int) -> ValidationResult:
274
+ violation = Violation(
275
+ code=Code.FILE_TOO_LARGE,
276
+ message=f"The file is too large (maximum {format_size(limit)}).",
277
+ check="size",
278
+ details={"size": self.received, "limit": limit, "aborted_upload": True},
279
+ )
280
+ return ValidationResult(
281
+ name=self.file_name or "",
282
+ field=self.field_name,
283
+ size=self.received,
284
+ file_type=None,
285
+ declared_type=self.content_type,
286
+ violations=(violation,),
287
+ checks_run=("size",),
288
+ )
289
+
290
+ def _check(self, result: ValidationResult) -> None:
291
+ if not result.ok:
292
+ self._refuse(result)
293
+
294
+ def _refuse(self, result: ValidationResult, *, force_abort: bool = False) -> None:
295
+ field = self.field_name or "file"
296
+ name = self.policy.name if self.policy is not None else "upload_limit"
297
+ self.outcomes.append(FileOutcome(field, self._index(field), result, name))
298
+ if self.inline and not force_abort:
299
+ self.early = result
300
+ return
301
+ self._abort()
302
+
303
+ def _inner_complete(self) -> UploadedFile | None:
304
+ if not self.inner_started:
305
+ return None
306
+ for i, handler in enumerate(self.inner):
307
+ if (file_obj := handler.file_complete(self.counters[i])) is not None:
308
+ return file_obj
309
+ return None
310
+
311
+ def _abort(self, rejected: UploadedFile | None = None) -> None:
312
+ validation = UploadValidation(tuple(self.outcomes), "enforce")
313
+ if self.request is not None:
314
+ finish(self.request, validation, self.spec) # logs, with the file's hash
315
+ if rejected is not None:
316
+ rejected.close() # never reaches request.FILES, so Django would not close it
317
+ self.upload_interrupted() # lets the storage handlers delete temporary files
318
+ raise UploadRejected(validation)
319
+
320
+ def _finish(self) -> None:
321
+ if self.request is not None and self.mode != "off":
322
+ finish(self.request, UploadValidation(tuple(self.outcomes), self.mode), self.spec)
@@ -0,0 +1,92 @@
1
+ """Responses for rejected uploads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from django.contrib import messages
8
+ from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
9
+ from django.shortcuts import resolve_url
10
+ from django.template import TemplateDoesNotExist
11
+ from django.template.loader import render_to_string
12
+ from django.utils.translation import gettext as _
13
+
14
+ from uploadshield import web
15
+
16
+ from .conf import get_config
17
+ from .service import message_for
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ from django.http import HttpRequest
23
+
24
+ from .service import UploadValidation
25
+
26
+ __all__ = ["redirect_with_messages", "reject", "rejection_payload", "status_code_for"]
27
+
28
+ TEMPLATE_NAME = "uploadshield/rejected.html"
29
+
30
+
31
+ def status_code_for(validation: UploadValidation) -> int:
32
+ """413 if every problem is size, 415 if every problem is the type, else 400."""
33
+ return web.status_code_for(o.result for o in validation.rejected)
34
+
35
+
36
+ def rejection_payload(
37
+ validation: UploadValidation, *, include_details: bool = False
38
+ ) -> dict[str, Any]:
39
+ """A JSON-serialisable description of the rejected files."""
40
+ return web.rejection_payload(
41
+ (o.result for o in validation.rejected),
42
+ include_details=include_details,
43
+ messages=get_config().messages,
44
+ message=_("The upload was rejected."),
45
+ fields=[o.field for o in validation.rejected],
46
+ )
47
+
48
+
49
+ def _wants_json(request: HttpRequest) -> bool:
50
+ return web.wants_json(request.headers.get("Accept"), request.headers.get("X-Requested-With"))
51
+
52
+
53
+ def reject(request: HttpRequest, validation: UploadValidation) -> HttpResponse:
54
+ """Default ``REJECTION_HANDLER``: JSON for API clients, HTML otherwise.
55
+
56
+ The HTML uses the overridable template ``uploadshield/rejected.html``.
57
+ Diagnostics are only included when ``EXPOSE_DETAILS`` is enabled.
58
+ """
59
+ payload = rejection_payload(validation, include_details=get_config().expose_details)
60
+ status = status_code_for(validation)
61
+ if _wants_json(request):
62
+ return JsonResponse(payload, status=status)
63
+ try:
64
+ body = render_to_string(TEMPLATE_NAME, {"rejection": payload}, request=request)
65
+ except TemplateDoesNotExist:
66
+ body = web.html_rejection(payload)
67
+ return HttpResponse(body, status=status)
68
+
69
+
70
+ def redirect_with_messages(
71
+ to: str, *, fallback_message: str | None = None
72
+ ) -> Callable[[HttpRequest, UploadValidation], HttpResponse]:
73
+ """Build a handler that redirects (URL name or path) and flashes each error.
74
+
75
+ Ideal for classic form views::
76
+
77
+ @validate_uploads("images", on_reject=redirect_with_messages("gallery"))
78
+ """
79
+
80
+ def handler(request: HttpRequest, validation: UploadValidation) -> HttpResponse:
81
+ config = get_config()
82
+ flashed = False
83
+ for outcome in validation.rejected:
84
+ for violation in outcome.result.violations:
85
+ text = f"{outcome.result.name}: {message_for(violation, config)}"
86
+ messages.error(request, text, fail_silently=True)
87
+ flashed = True
88
+ if not flashed and fallback_message:
89
+ messages.error(request, fallback_message, fail_silently=True)
90
+ return HttpResponseRedirect(resolve_url(to))
91
+
92
+ return handler