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,42 @@
1
+ """Flask integration.
2
+
3
+ ::
4
+
5
+ from flask import Flask
6
+ from uploadshield.flask import UploadShield, validate_uploads
7
+
8
+ app = Flask(__name__)
9
+ app.config["UPLOADSHIELD"] = {"MODE": "report"}
10
+ app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # Werkzeug's whole-request cap
11
+ UploadShield(app) # validates every multipart request before its view runs
12
+
13
+ @app.post("/avatar")
14
+ @validate_uploads("sanitized_images")
15
+ def avatar(): ...
16
+
17
+ Werkzeug parses the body before validation (it has no stable per-file streaming hook),
18
+ so keep ``MAX_CONTENT_LENGTH`` set. Refusals are Werkzeug HTTP errors
19
+ (:class:`UploadRejected`, 400/413/415) rendered as JSON for API clients and HTML
20
+ otherwise. For inline form errors use :class:`uploadshield.flask.wtforms.ShieldedFile`.
21
+ """
22
+
23
+ from .core import (
24
+ FileOutcome,
25
+ UploadRejected,
26
+ UploadShield,
27
+ get_upload_results,
28
+ upload_rejected,
29
+ upload_validated,
30
+ )
31
+ from .decorators import skip_upload_validation, validate_uploads
32
+
33
+ __all__ = [
34
+ "FileOutcome",
35
+ "UploadRejected",
36
+ "UploadShield",
37
+ "get_upload_results",
38
+ "skip_upload_validation",
39
+ "upload_rejected",
40
+ "upload_validated",
41
+ "validate_uploads",
42
+ ]
@@ -0,0 +1,359 @@
1
+ """Flask integration: configuration, request validation and the error type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import fnmatch
7
+ import io
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from types import MappingProxyType
11
+ from typing import TYPE_CHECKING, Any, Final, cast
12
+
13
+ from blinker import Namespace
14
+ from flask import Flask, Response, current_app, has_request_context, request
15
+ from werkzeug.datastructures import FileStorage, ImmutableMultiDict
16
+ from werkzeug.exceptions import HTTPException
17
+
18
+ from uploadshield import web
19
+ from uploadshield.core.upload import Upload
20
+ from uploadshield.engine import validate
21
+ from uploadshield.policy import Policy
22
+ from uploadshield.presets import BUILTIN_POLICIES, DEFAULT
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable, Mapping
26
+
27
+ from uploadshield.core.result import ValidationResult
28
+
29
+ __all__ = [
30
+ "SKIP",
31
+ "FileOutcome",
32
+ "UploadRejected",
33
+ "UploadShield",
34
+ "ViewSpec",
35
+ "get_upload_results",
36
+ "upload_rejected",
37
+ "upload_validated",
38
+ "validate_request",
39
+ "validate_storage",
40
+ ]
41
+
42
+ EXTENSION: Final = "uploadshield"
43
+ VIEW_ATTR: Final = "_uploadshield_spec"
44
+ _CACHE_ATTR: Final = "_uploadshield_results"
45
+ # Per-request state lives in the WSGI environ: ``g`` belongs to the app context, which
46
+ # a pushed context (tests, batch jobs) shares between requests.
47
+ _ENV_SPEC: Final = "uploadshield.spec"
48
+ _ENV_RESULTS: Final = "uploadshield.results"
49
+ MODES = ("enforce", "report", "off")
50
+
51
+ _signals = Namespace()
52
+ #: Sent for every validated request; kwargs ``results`` (list of :class:`FileOutcome`).
53
+ upload_validated = _signals.signal("uploadshield.upload-validated")
54
+ #: Sent when files are refused; kwargs ``results`` and ``enforced``.
55
+ upload_rejected = _signals.signal("uploadshield.upload-rejected")
56
+
57
+ _DEFAULTS: dict[str, Any] = {
58
+ "MODE": "enforce",
59
+ "DEFAULT_POLICY": "default",
60
+ "POLICIES": {},
61
+ "ENDPOINT_POLICIES": {},
62
+ "EXPOSE_DETAILS": False,
63
+ "MESSAGES": {},
64
+ "PROTECT_ALL": True,
65
+ }
66
+
67
+
68
+ class _UseDefault:
69
+ def __repr__(self) -> str:
70
+ return "USE_DEFAULT"
71
+
72
+
73
+ USE_DEFAULT: Final = _UseDefault()
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class State:
78
+ mode: str = "enforce"
79
+ default_policy: Policy | None = DEFAULT
80
+ policies: Mapping[str, Policy] = field(
81
+ default_factory=lambda: MappingProxyType(BUILTIN_POLICIES)
82
+ )
83
+ endpoint_policies: tuple[tuple[str, Policy | None], ...] = ()
84
+ expose_details: bool = False
85
+ messages: Mapping[str, Any] = field(default_factory=dict)
86
+ protect_all: bool = True
87
+
88
+ def resolve(self, ref: Any) -> Policy:
89
+ try:
90
+ return web.resolve_policy(ref, self.policies)
91
+ except web.PolicyLookupError as exc:
92
+ raise ValueError(str(exc)) from exc
93
+
94
+
95
+ def _load_state(raw: Mapping[str, Any]) -> State:
96
+ unknown = set(raw) - set(_DEFAULTS)
97
+ if unknown:
98
+ raise ValueError(
99
+ f"Unknown UPLOADSHIELD keys: {', '.join(sorted(unknown))}. "
100
+ f"Valid keys: {', '.join(_DEFAULTS)}."
101
+ )
102
+ values = {**_DEFAULTS, **raw}
103
+ if values["MODE"] not in MODES:
104
+ raise ValueError(f"UPLOADSHIELD['MODE'] must be one of {MODES}, got {values['MODE']!r}")
105
+ policies = dict(BUILTIN_POLICIES)
106
+ for name, policy in dict(values["POLICIES"]).items():
107
+ if not isinstance(policy, Policy):
108
+ raise TypeError(f"UPLOADSHIELD['POLICIES'][{name!r}] must be a Policy")
109
+ policies[name] = policy
110
+ base = State(policies=MappingProxyType(policies))
111
+ default = values["DEFAULT_POLICY"]
112
+ return dataclasses.replace(
113
+ base,
114
+ mode=values["MODE"],
115
+ default_policy=None if default is None else base.resolve(default),
116
+ endpoint_policies=tuple(
117
+ (pattern, None if ref is None else base.resolve(ref))
118
+ for pattern, ref in dict(values["ENDPOINT_POLICIES"]).items()
119
+ ),
120
+ expose_details=bool(values["EXPOSE_DETAILS"]),
121
+ messages=MappingProxyType(dict(values["MESSAGES"])),
122
+ protect_all=bool(values["PROTECT_ALL"]),
123
+ )
124
+
125
+
126
+ def _state() -> State:
127
+ """The app's configuration, or the defaults when ``init_app`` was not called."""
128
+ return cast("State", current_app.extensions.get(EXTENSION) or _FALLBACK)
129
+
130
+
131
+ _FALLBACK = State()
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class ViewSpec:
136
+ """How uploads to one endpoint are validated (from decorators or config)."""
137
+
138
+ policy: Any = USE_DEFAULT
139
+ fields: Mapping[str, Any] = field(default_factory=dict)
140
+ mode: str | None = None
141
+ on_reject: Callable[[list[FileOutcome]], Any] | None = None
142
+
143
+ def __post_init__(self) -> None:
144
+ if self.mode is not None and self.mode not in MODES:
145
+ raise ValueError(f"mode must be one of {MODES}, got {self.mode!r}")
146
+ object.__setattr__(self, "fields", MappingProxyType(dict(self.fields)))
147
+
148
+ def policy_for(self, field_name: str, state: State) -> Policy | None:
149
+ ref = self.fields.get(field_name, self.policy)
150
+ if ref is None:
151
+ return None
152
+ if isinstance(ref, _UseDefault):
153
+ return state.default_policy or DEFAULT
154
+ return state.resolve(ref)
155
+
156
+
157
+ SKIP: Final = ViewSpec(policy=None, mode="off")
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class FileOutcome:
162
+ field: str
163
+ result: ValidationResult
164
+ policy_name: str
165
+ storage: FileStorage
166
+
167
+ @property
168
+ def ok(self) -> bool:
169
+ return self.result.ok
170
+
171
+
172
+ class UploadRejected(HTTPException):
173
+ """Refused upload: a Werkzeug HTTP error (400/413/415) with a JSON or HTML body.
174
+
175
+ Flask renders it like any ``abort()``; register an error handler for it to
176
+ customise the response application-wide.
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ outcomes: list[FileOutcome],
182
+ *,
183
+ expose_details: bool = False,
184
+ messages: Mapping[str, Any] | None = None,
185
+ response: Response | None = None,
186
+ ) -> None:
187
+ self.outcomes = outcomes
188
+ results = [o.result for o in outcomes]
189
+ self.payload = web.rejection_payload(
190
+ results,
191
+ include_details=expose_details,
192
+ messages=messages,
193
+ fields=[o.field for o in outcomes],
194
+ )
195
+ super().__init__(description=self.payload["message"], response=response)
196
+ self.code = web.status_code_for(results)
197
+
198
+ def get_response(self, environ: Any = None, scope: Any = None) -> Response: # type: ignore[override]
199
+ if self.response is not None:
200
+ return cast("Response", self.response)
201
+ status = self.code or 400
202
+ html = has_request_context() and not web.wants_json(
203
+ request.headers.get("Accept"), request.headers.get("X-Requested-With")
204
+ )
205
+ if html:
206
+ return Response(web.html_rejection(self.payload), status=status, mimetype="text/html")
207
+ body = json.dumps(web.json_safe(self.payload)) # details may hold sets or bytes
208
+ return Response(body, status=status, mimetype="application/json")
209
+
210
+
211
+ def validate_storage(storage: FileStorage, policy: Policy, *, field: str) -> ValidationResult:
212
+ """Validate a Werkzeug ``FileStorage``, memoising the result on it."""
213
+ cache: dict[Policy, ValidationResult] = storage.__dict__.setdefault(_CACHE_ATTR, {})
214
+ if policy not in cache:
215
+ cache[policy] = validate(
216
+ Upload(
217
+ stream=storage.stream,
218
+ name=storage.filename or "",
219
+ declared_type=storage.mimetype or None,
220
+ field=field,
221
+ ),
222
+ policy,
223
+ )
224
+ return cache[policy]
225
+
226
+
227
+ def deliver(storage: FileStorage, result: ValidationResult, policy: Policy) -> FileStorage:
228
+ """The file the view receives: sanitized, with the detected content type."""
229
+ if not result.ok or result.file_type is None:
230
+ return storage
231
+ sanitized = result.sanitized
232
+ stream: Any = storage.stream
233
+ name = storage.filename
234
+ content_type = result.file_type.mime
235
+ if sanitized is not None:
236
+ name, content_type = sanitized.name, sanitized.content_type
237
+ if sanitized.data is not None:
238
+ stream = io.BytesIO(sanitized.data)
239
+ replacement = FileStorage(stream, filename=name, name=storage.name, content_type=content_type)
240
+ done = dataclasses.replace(result, sanitized=None, name=name or result.name)
241
+ replacement.__dict__[_CACHE_ATTR] = {policy: done}
242
+ return replacement
243
+
244
+
245
+ def get_upload_results() -> list[FileOutcome] | None:
246
+ """The outcomes for the current request, once its files were validated."""
247
+ return request.environ.get(_ENV_RESULTS)
248
+
249
+
250
+ def validate_request(spec: ViewSpec) -> list[FileOutcome]:
251
+ """Validate ``request.files`` against ``spec``; refuse or swap in sanitized files."""
252
+ if request.environ.get(_ENV_SPEC) == spec:
253
+ return request.environ.get(_ENV_RESULTS) or []
254
+ state = _state()
255
+ mode = spec.mode or state.mode
256
+ if mode == "off":
257
+ return []
258
+ outcomes: list[FileOutcome] = []
259
+ delivered: list[tuple[str, FileStorage]] = []
260
+ replaced: list[FileStorage] = []
261
+ for field_name, storage in request.files.items(multi=True):
262
+ policy = spec.policy_for(field_name, state)
263
+ if policy is None or web.is_unused_file_input(storage.filename, storage.stream):
264
+ delivered.append((field_name, storage))
265
+ continue
266
+ result = validate_storage(storage, policy, field=field_name)
267
+ outcomes.append(FileOutcome(field_name, result, policy.name, storage))
268
+ replacement = deliver(storage, result, policy)
269
+ delivered.append((field_name, replacement))
270
+ if replacement.stream is not storage.stream:
271
+ replaced.append(storage)
272
+
273
+ rejected = [o for o in outcomes if not o.ok]
274
+ for o in rejected:
275
+ web.log_rejection(
276
+ o.result,
277
+ field=o.field,
278
+ policy_name=o.policy_name,
279
+ mode=mode,
280
+ path=request.path,
281
+ sha256=web.sha256_of(o.storage.stream),
282
+ )
283
+ request.environ[_ENV_SPEC] = spec
284
+ request.environ[_ENV_RESULTS] = outcomes
285
+ app = current_app._get_current_object() # ty: ignore[unresolved-attribute] - proxy API
286
+ upload_validated.send(app, results=outcomes)
287
+ if rejected:
288
+ upload_rejected.send(app, results=rejected, enforced=mode == "enforce")
289
+ if mode == "enforce":
290
+ response = None
291
+ if spec.on_reject is not None:
292
+ response = current_app.make_response(spec.on_reject(rejected))
293
+ raise UploadRejected(
294
+ rejected,
295
+ expose_details=state.expose_details,
296
+ messages=state.messages,
297
+ response=response,
298
+ )
299
+ request.__dict__["files"] = ImmutableMultiDict(delivered) # Werkzeug caches it here
300
+ # Werkzeug closes what is in request.files when the request ends; originals replaced
301
+ # by sanitized copies are no longer there, so close them now.
302
+ for original in replaced:
303
+ original.close()
304
+ return outcomes
305
+
306
+
307
+ def get_view_spec(view: Any) -> ViewSpec | None:
308
+ spec = getattr(view, VIEW_ATTR, None)
309
+ if spec is None:
310
+ spec = getattr(getattr(view, "view_class", None), VIEW_ATTR, None)
311
+ return spec if isinstance(spec, ViewSpec) else None
312
+
313
+
314
+ def _wants_validation() -> bool:
315
+ # Werkzeug parses multipart bodies for every method (GET and DELETE included).
316
+ return request.mimetype == "multipart/form-data"
317
+
318
+
319
+ class UploadShield:
320
+ """The Flask extension: ``UploadShield(app)`` or ``shield.init_app(app)``.
321
+
322
+ Reads ``app.config["UPLOADSHIELD"]`` (``MODE``, ``DEFAULT_POLICY``, ``POLICIES``,
323
+ ``ENDPOINT_POLICIES``, ``EXPOSE_DETAILS``, ``MESSAGES``, ``PROTECT_ALL``). With
324
+ ``PROTECT_ALL`` (the default) every multipart request is validated before its view
325
+ runs, using the view's ``@validate_uploads`` spec, else the first matching
326
+ ``ENDPOINT_POLICIES`` glob (on ``request.endpoint``), else ``DEFAULT_POLICY``.
327
+ """
328
+
329
+ def __init__(self, app: Flask | None = None) -> None:
330
+ if app is not None:
331
+ self.init_app(app)
332
+
333
+ def init_app(self, app: Flask) -> None:
334
+ state = _load_state(app.config.get("UPLOADSHIELD", {}))
335
+ app.extensions[EXTENSION] = state
336
+ if state.protect_all:
337
+ app.before_request(_protect)
338
+
339
+
340
+ def _protect() -> None:
341
+ if not _wants_validation():
342
+ return
343
+ state = _state()
344
+ view = current_app.view_functions.get(request.endpoint) if request.endpoint else None
345
+ spec = get_view_spec(view)
346
+ if spec is None:
347
+ spec = ViewSpec(policy=state.default_policy)
348
+ for pattern, policy in state.endpoint_policies:
349
+ if request.endpoint and fnmatch.fnmatchcase(request.endpoint, pattern):
350
+ spec = SKIP if policy is None else ViewSpec(policy=policy)
351
+ break
352
+ if spec.mode != "off":
353
+ validate_request(spec)
354
+
355
+
356
+ def check_request(spec: ViewSpec) -> None:
357
+ """Used by the decorator: validate unless the request needs no validation."""
358
+ if spec.mode != "off" and _wants_validation():
359
+ validate_request(spec)
@@ -0,0 +1,69 @@
1
+ """Per-view configuration: ``@validate_uploads`` and ``@skip_upload_validation``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from collections.abc import Callable, Mapping
8
+ from typing import Any, TypeVar, cast
9
+
10
+ from uploadshield.policy import Policy
11
+
12
+ from .core import SKIP, USE_DEFAULT, VIEW_ATTR, FileOutcome, ViewSpec, check_request
13
+
14
+ __all__ = ["skip_upload_validation", "validate_uploads"]
15
+
16
+ V = TypeVar("V", bound=Callable[..., Any])
17
+
18
+
19
+ def _apply(view: V, spec: ViewSpec) -> V:
20
+ if inspect.iscoroutinefunction(view):
21
+
22
+ @functools.wraps(view)
23
+ async def async_view(*args: Any, **kwargs: Any) -> Any:
24
+ check_request(spec)
25
+ return await view(*args, **kwargs)
26
+
27
+ wrapped: Callable[..., Any] = async_view
28
+ else:
29
+
30
+ @functools.wraps(view)
31
+ def sync_view(*args: Any, **kwargs: Any) -> Any:
32
+ check_request(spec)
33
+ return view(*args, **kwargs)
34
+
35
+ wrapped = sync_view
36
+ setattr(wrapped, VIEW_ATTR, spec)
37
+ return cast("V", wrapped)
38
+
39
+
40
+ def validate_uploads(
41
+ policy: Any = USE_DEFAULT,
42
+ /,
43
+ *,
44
+ fields: Mapping[str, Any] | None = None,
45
+ mode: str | None = None,
46
+ on_reject: Callable[[list[FileOutcome]], Any] | None = None,
47
+ ) -> Any:
48
+ """Validate the files uploaded to a Flask view.
49
+
50
+ ``policy`` is a :class:`~uploadshield.Policy`, a name from
51
+ ``UPLOADSHIELD["POLICIES"]``/the presets, or a dotted path; ``fields`` overrides it
52
+ per form field (``None`` skips a field). ``on_reject`` receives the refused
53
+ outcomes and returns a response (e.g. ``flash()`` + ``redirect()``). Works with or
54
+ without the extension's request hook::
55
+
56
+ @app.post("/avatar")
57
+ @validate_uploads("sanitized_images")
58
+ def avatar(): ...
59
+ """
60
+ if callable(policy) and not isinstance(policy, Policy): # bare ``@validate_uploads``
61
+ return _apply(policy, ViewSpec())
62
+ spec = ViewSpec(policy=policy, fields=fields or {}, mode=mode, on_reject=on_reject)
63
+ return functools.partial(_apply, spec=spec)
64
+
65
+
66
+ def skip_upload_validation(view: V) -> V:
67
+ """Exempt a view from the extension's request hook."""
68
+ setattr(view, VIEW_ATTR, SKIP)
69
+ return view
@@ -0,0 +1,58 @@
1
+ """A WTForms / Flask-WTF validator for inline form errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, ClassVar
6
+
7
+ from werkzeug.datastructures import FileStorage
8
+
9
+ from uploadshield import web
10
+
11
+ from .core import _state, deliver, validate_storage
12
+
13
+ __all__ = ["ShieldedFile"]
14
+
15
+
16
+ class ShieldedFile:
17
+ """Validate (and sanitize) a ``FileField``/``MultipleFileField``::
18
+
19
+ class AvatarForm(FlaskForm):
20
+ picture = FileField(validators=[FileRequired(), ShieldedFile("sanitized_images")])
21
+
22
+ Refusals become ordinary form errors; on success ``field.data`` holds the
23
+ sanitized file. Empty fields are left to ``FileRequired``.
24
+ """
25
+
26
+ field_flags: ClassVar[dict[str, Any]] = {"accept": None}
27
+
28
+ def __init__(self, policy: Any = "default") -> None:
29
+ self.policy = policy
30
+
31
+ def __call__(self, form: Any, field: Any) -> None:
32
+ from wtforms.validators import StopValidation
33
+
34
+ files = field.data if isinstance(field.data, list) else [field.data]
35
+ if not any(_is_upload(f) for f in files):
36
+ return
37
+ state = _state()
38
+ policy = state.resolve(self.policy)
39
+ delivered = []
40
+ for storage in files:
41
+ if not _is_upload(storage):
42
+ delivered.append(storage)
43
+ continue
44
+ result = validate_storage(storage, policy, field=field.name)
45
+ if not result.ok:
46
+ web.log_rejection(result, field=field.name, policy_name=policy.name, mode="enforce")
47
+ raise StopValidation(
48
+ " ".join(web.message_for(v, state.messages) for v in result.violations)
49
+ )
50
+ delivered.append(deliver(storage, result, policy))
51
+ field.data = delivered if isinstance(field.data, list) else delivered[0]
52
+
53
+
54
+ def _is_upload(value: Any) -> bool:
55
+ """A submitted file; inputs left empty are ``FileRequired``'s business."""
56
+ return isinstance(value, FileStorage) and not web.is_unused_file_input(
57
+ value.filename, value.stream
58
+ )
uploadshield/policy.py ADDED
@@ -0,0 +1,137 @@
1
+ """Policies: declarative, immutable descriptions of what an upload may be."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .checks import (
10
+ DEFAULT_CONTENT_CHECKS,
11
+ AllowedTypes,
12
+ Check,
13
+ DeclaredTypeMatches,
14
+ ExtensionMatchesType,
15
+ FilenameCheck,
16
+ FileSize,
17
+ )
18
+ from .checks.basic import normalize_allowed_types
19
+ from .core._deconstruct import Deconstructible
20
+ from .core.check import Sanitizer
21
+ from .core.sniffing import BuiltinSniffer, Sniffer
22
+ from .core.types import MB, FileType, get_type
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Collection, Iterable
26
+
27
+ __all__ = ["Policy"]
28
+
29
+
30
+ @dataclass(frozen=True, kw_only=True)
31
+ class Policy(Deconstructible):
32
+ """What an upload must look like to be accepted, and how to sanitize it.
33
+
34
+ The pipeline always runs in cost order and stops at the first failing check
35
+ (``fail_fast``), so expensive parsers never see files that already failed
36
+ basic checks:
37
+
38
+ 1. size, 2. filename, 3. content type allow-list, 4. extension/content agreement,
39
+ 5. (optional) declared ``Content-Type`` agreement, 6. format-specific
40
+ ``content_checks``, 7. your additional ``checks`` (e.g. ClamAV, YARA).
41
+
42
+ Policies are immutable and hashable; derive variants with :meth:`replace` and
43
+ :meth:`extend`.
44
+ """
45
+
46
+ _deconstruct_module = "uploadshield"
47
+
48
+ allowed_types: Collection[str | FileType]
49
+ max_size: int | None = 10 * MB
50
+ allow_empty: bool = False
51
+ filename: FilenameCheck = FilenameCheck()
52
+ match_extension: bool = True
53
+ match_declared_type: bool = False
54
+ content_checks: tuple[Check, ...] = DEFAULT_CONTENT_CHECKS
55
+ checks: tuple[Check, ...] = ()
56
+ sanitizers: tuple[Sanitizer, ...] = ()
57
+ sniffer: Sniffer = BuiltinSniffer()
58
+ fail_fast: bool = True
59
+ name: str = ""
60
+
61
+ pipeline: tuple[Check, ...] = field(init=False, repr=False, compare=False)
62
+ _type_check: AllowedTypes = field(init=False, repr=False, compare=False)
63
+
64
+ def __post_init__(self) -> None:
65
+ type_check = AllowedTypes(normalize_allowed_types(_as_iterable(self.allowed_types)))
66
+ set_ = object.__setattr__
67
+ set_(self, "allowed_types", type_check.types)
68
+ set_(self, "_type_check", type_check)
69
+ set_(self, "content_checks", tuple(self.content_checks))
70
+ set_(self, "checks", tuple(self.checks))
71
+ set_(self, "sanitizers", tuple(self.sanitizers))
72
+ if self.max_size is not None and self.max_size < 0:
73
+ raise ValueError("max_size must be a positive number of bytes or None")
74
+ for check in (*self.content_checks, *self.checks):
75
+ if not isinstance(check, Check):
76
+ raise TypeError(f"{check!r} is not a Check")
77
+ for sanitizer in self.sanitizers:
78
+ if not isinstance(sanitizer, Sanitizer):
79
+ raise TypeError(f"{sanitizer!r} is not a Sanitizer")
80
+ if not hasattr(self.sniffer, "sniff"):
81
+ raise TypeError(f"{self.sniffer!r} is not a Sniffer")
82
+
83
+ pipeline: list[Check] = [
84
+ FileSize(max_bytes=self.max_size, allow_empty=self.allow_empty),
85
+ self.filename,
86
+ type_check,
87
+ ]
88
+ if self.match_extension:
89
+ pipeline.append(ExtensionMatchesType())
90
+ if self.match_declared_type:
91
+ pipeline.append(DeclaredTypeMatches())
92
+ pipeline += [*self.content_checks, *self.checks]
93
+ set_(self, "pipeline", tuple(pipeline))
94
+
95
+ # -- derivation -------------------------------------------------------------------
96
+
97
+ def replace(self, **changes: Any) -> Policy:
98
+ """A copy with some fields changed: ``IMAGES.replace(max_size=2 * MB)``."""
99
+ return dataclasses.replace(self, **changes)
100
+
101
+ def extend(
102
+ self,
103
+ *,
104
+ allowed_types: Iterable[str | FileType] = (),
105
+ checks: Iterable[Check] = (),
106
+ sanitizers: Iterable[Sanitizer] = (),
107
+ name: str | None = None,
108
+ ) -> Policy:
109
+ """A copy that additionally allows types and runs more checks/sanitizers."""
110
+ return dataclasses.replace(
111
+ self,
112
+ allowed_types=self.types | normalize_allowed_types(allowed_types),
113
+ checks=(*self.checks, *checks),
114
+ sanitizers=(*self.sanitizers, *sanitizers),
115
+ name=self.name if name is None else name,
116
+ )
117
+
118
+ @property
119
+ def types(self) -> frozenset[str]:
120
+ """The normalized allow-list (canonical MIME types and wildcards)."""
121
+ return self._type_check.types
122
+
123
+ def allows(self, file_type: FileType) -> bool:
124
+ """Whether content of ``file_type`` passes the type allow-list."""
125
+ return self._type_check.allows(file_type)
126
+
127
+ def allows_mime(self, mime: str) -> bool:
128
+ """Like :meth:`allows` for a MIME string; unknown types are never allowed."""
129
+ file_type = get_type(mime)
130
+ return file_type is not None and self.allows(file_type)
131
+
132
+
133
+ def _as_iterable(value: Iterable[str | FileType] | str | FileType) -> Iterable[str | FileType]:
134
+ # Guard against Policy(allowed_types="image/png"), which would iterate characters.
135
+ if isinstance(value, (str, FileType)):
136
+ return (value,)
137
+ return value