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,148 @@
1
+ """Django system checks (``manage.py check``) for configuration mistakes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from django.conf import settings
8
+ from django.core import checks
9
+ from django.core.exceptions import ImproperlyConfigured
10
+
11
+ from uploadshield.checks import ImageIntegrity, YaraRules
12
+ from uploadshield.checks.image import PILLOW_FORMATS
13
+
14
+ from .conf import SETTING, load_config
15
+
16
+ if TYPE_CHECKING:
17
+ from uploadshield.policy import Policy
18
+
19
+ __all__ = ["check_configuration"]
20
+
21
+ _HANDLER = "uploadshield.django.handlers.ValidatingUploadHandler"
22
+
23
+
24
+ def _uses_our_handler400() -> bool:
25
+ from importlib import import_module
26
+
27
+ from django.utils.module_loading import import_string
28
+
29
+ from .views import bad_request
30
+
31
+ urlconf = getattr(settings, "ROOT_URLCONF", None)
32
+ if not urlconf:
33
+ return False
34
+ try:
35
+ handler = getattr(import_module(urlconf), "handler400", None)
36
+ if isinstance(handler, str):
37
+ handler = import_string(handler)
38
+ except ImportError:
39
+ return False
40
+ return handler is bad_request
41
+
42
+
43
+ def _distinct_policies(config: Any) -> list[Policy]:
44
+ seen: dict[Policy, None] = {}
45
+ candidates = [*config.policies.values(), config.default_policy]
46
+ candidates += [policy for _, policy in config.view_policies]
47
+ for policy in candidates:
48
+ if policy is not None:
49
+ seen.setdefault(policy, None)
50
+ return list(seen)
51
+
52
+
53
+ def _policy_messages(policy: Policy) -> list[checks.CheckMessage]:
54
+ label = policy.name or repr(policy)
55
+ found: list[checks.CheckMessage] = []
56
+ for component in (*policy.checks, *policy.sanitizers):
57
+ if missing := component.missing_dependencies():
58
+ found.append(
59
+ checks.Error(
60
+ f"Upload policy {label!r} uses {type(component).__name__}, which requires "
61
+ f"the missing module(s): {', '.join(missing)}.",
62
+ hint="Install the matching extra, e.g. uv add 'uploadshield[images,yara]'.",
63
+ id="uploadshield.E002",
64
+ )
65
+ )
66
+ elif isinstance(component, YaraRules):
67
+ try:
68
+ component.compile()
69
+ except Exception as exc:
70
+ found.append(
71
+ checks.Error(
72
+ f"YARA rules of upload policy {label!r} do not compile: {exc}",
73
+ id="uploadshield.E003",
74
+ )
75
+ )
76
+ raster = any(policy.allows_mime(mime) for mime in PILLOW_FORMATS)
77
+ uses_pillow = any(isinstance(c, ImageIntegrity) for c in policy.content_checks)
78
+ if raster and uses_pillow and ImageIntegrity.missing_dependencies():
79
+ found.append(
80
+ checks.Warning(
81
+ f"Upload policy {label!r} accepts images but Pillow is not installed, so image "
82
+ "integrity checks are skipped.",
83
+ hint="uv add 'uploadshield[images]'",
84
+ id="uploadshield.W002",
85
+ )
86
+ )
87
+ return found
88
+
89
+
90
+ @checks.register(checks.Tags.security)
91
+ def check_configuration(app_configs: Any = None, **kwargs: Any) -> list[checks.CheckMessage]:
92
+ try:
93
+ config = load_config()
94
+ except ImproperlyConfigured as exc:
95
+ return [checks.Error(str(exc), obj=SETTING, id="uploadshield.E001")]
96
+
97
+ messages: list[checks.CheckMessage] = []
98
+ for policy in _distinct_policies(config):
99
+ messages += _policy_messages(policy)
100
+
101
+ handlers = list(getattr(settings, "FILE_UPLOAD_HANDLERS", []))
102
+ if _HANDLER not in handlers:
103
+ messages.append(
104
+ checks.Info(
105
+ "ValidatingUploadHandler is not installed: only views with @validate_uploads, "
106
+ "fields with FileValidator/ValidatedFileField and ValidatingStorage are protected.",
107
+ hint=f"Set FILE_UPLOAD_HANDLERS = [{_HANDLER!r}] for project-wide protection.",
108
+ id="uploadshield.I001",
109
+ )
110
+ )
111
+ if config.max_upload_size is not None:
112
+ messages.append(
113
+ checks.Warning(
114
+ f"{SETTING}['MAX_UPLOAD_SIZE'] has no effect without ValidatingUploadHandler.",
115
+ id="uploadshield.W003",
116
+ )
117
+ )
118
+ else:
119
+ if handlers.index(_HANDLER) != 0:
120
+ messages.append(
121
+ checks.Warning(
122
+ "Upload handlers before ValidatingUploadHandler receive (and may store) file "
123
+ "data before it is validated.",
124
+ hint="Make ValidatingUploadHandler the only entry of FILE_UPLOAD_HANDLERS and "
125
+ f"list the others in {SETTING}['UPLOAD_HANDLERS'].",
126
+ id="uploadshield.W004",
127
+ )
128
+ )
129
+ if len(handlers) > 1:
130
+ messages.append(
131
+ checks.Warning(
132
+ "Upload handlers listed next to ValidatingUploadHandler never receive data; it "
133
+ f"delegates storage to {SETTING}['UPLOAD_HANDLERS'] instead.",
134
+ hint=f"Move {[h for h in handlers if h != _HANDLER]!r} to "
135
+ f"{SETTING}['UPLOAD_HANDLERS'].",
136
+ id="uploadshield.W001",
137
+ )
138
+ )
139
+ if not _uses_our_handler400():
140
+ messages.append(
141
+ checks.Info(
142
+ "Rejected uploads are answered by your handler400 with a generic 400 page.",
143
+ hint="Set handler400 = 'uploadshield.django.views.bad_request' in your "
144
+ "root URLconf for 413/415 status codes and JSON/HTML error details.",
145
+ id="uploadshield.I002",
146
+ )
147
+ )
148
+ return messages
@@ -0,0 +1,36 @@
1
+ """The command line's bridge into a Django project (``uploadshield ... --django``).
2
+
3
+ The only place that calls ``django.setup()``: the command line itself stays free of
4
+ Django imports and loads this module only when asked to.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from .conf import Config
14
+
15
+ __all__ = ["configure"]
16
+
17
+
18
+ def configure(settings: str | None = None) -> Config:
19
+ """Set Django up with ``settings`` (or ``DJANGO_SETTINGS_MODULE``); return the config.
20
+
21
+ Raises ``ImproperlyConfigured`` and import errors unchanged, for the caller to report.
22
+ """
23
+ if settings:
24
+ os.environ["DJANGO_SETTINGS_MODULE"] = settings
25
+ if not os.environ.get("DJANGO_SETTINGS_MODULE"):
26
+ from django.core.exceptions import ImproperlyConfigured
27
+
28
+ raise ImproperlyConfigured(
29
+ "no settings: pass --settings myproject.settings or set DJANGO_SETTINGS_MODULE"
30
+ )
31
+ import django
32
+
33
+ django.setup()
34
+ from .conf import get_config
35
+
36
+ return get_config()
@@ -0,0 +1,189 @@
1
+ """The ``UPLOADSHIELD`` setting: parsing, validation and policy lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import functools
7
+ from collections.abc import Callable, Mapping
8
+ from dataclasses import dataclass
9
+ from types import MappingProxyType
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from django.conf import settings
13
+ from django.core.exceptions import ImproperlyConfigured
14
+ from django.core.signals import setting_changed
15
+ from django.dispatch import receiver
16
+ from django.utils.module_loading import import_string
17
+
18
+ from uploadshield.policy import Policy
19
+ from uploadshield.presets import BUILTIN_POLICIES
20
+ from uploadshield.web import PolicyLookupError, resolve_policy
21
+
22
+ if TYPE_CHECKING:
23
+ from django.http import HttpRequest, HttpResponse
24
+
25
+ from .service import UploadValidation
26
+
27
+ __all__ = ["MODES", "ON_REJECT", "SETTING", "Config", "PolicyRef", "get_config", "load_config"]
28
+
29
+ SETTING = "UPLOADSHIELD"
30
+ #: ``enforce`` rejects, ``report`` only logs/signals what would be rejected, ``off``
31
+ #: disables request-level validation (field validators keep working).
32
+ MODES = ("enforce", "report", "off")
33
+ #: ``reject`` aborts the request (400/413/415); ``field`` replaces the file with an empty
34
+ #: placeholder so forms report the error next to the field.
35
+ ON_REJECT = ("reject", "field")
36
+ _OWN_HANDLER = "uploadshield.django.handlers.ValidatingUploadHandler"
37
+
38
+ PolicyRef = str | Policy
39
+ RejectionHandler = Callable[["HttpRequest", "UploadValidation"], "HttpResponse"]
40
+
41
+ _DEFAULTS: dict[str, Any] = {
42
+ "MODE": "enforce",
43
+ "DEFAULT_POLICY": "default",
44
+ "POLICIES": {},
45
+ "VIEW_POLICIES": {},
46
+ "REJECTION_HANDLER": "uploadshield.django.responses.reject",
47
+ "EXPOSE_DETAILS": False,
48
+ "NORMALIZE_CONTENT_TYPE": True,
49
+ "MAX_UPLOAD_SIZE": None,
50
+ "ON_REJECT": "reject",
51
+ # Where ValidatingUploadHandler stores incoming data (Django's defaults).
52
+ "UPLOAD_HANDLERS": [
53
+ "django.core.files.uploadhandler.MemoryFileUploadHandler",
54
+ "django.core.files.uploadhandler.TemporaryFileUploadHandler",
55
+ ],
56
+ "MESSAGES": {},
57
+ }
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Config:
62
+ mode: str
63
+ default_policy: Policy | None
64
+ policies: Mapping[str, Policy]
65
+ view_policies: tuple[tuple[str, Policy | None], ...]
66
+ rejection_handler: RejectionHandler
67
+ expose_details: bool
68
+ normalize_content_type: bool
69
+ max_upload_size: int | None
70
+ messages: Mapping[str, str]
71
+ on_reject: str
72
+ upload_handlers: tuple[str, ...]
73
+
74
+ def resolve(self, ref: PolicyRef) -> Policy:
75
+ """Resolve a policy object, a registered name, or a dotted import path."""
76
+ return _resolve(ref, self.policies)
77
+
78
+ def policy_for_view_name(self, view_name: str | None) -> tuple[bool, Policy | None]:
79
+ """``(matched, policy)`` for the first ``VIEW_POLICIES`` glob matching the name."""
80
+ if view_name:
81
+ for pattern, policy in self.view_policies:
82
+ if fnmatch.fnmatchcase(view_name, pattern):
83
+ return True, policy
84
+ return False, None
85
+
86
+
87
+ def _resolve(ref: PolicyRef, policies: Mapping[str, Policy]) -> Policy:
88
+ try:
89
+ return resolve_policy(ref, policies)
90
+ except PolicyLookupError as exc:
91
+ raise ImproperlyConfigured(str(exc)) from exc
92
+
93
+
94
+ def load_config() -> Config:
95
+ """Build a :class:`Config` from settings, raising ``ImproperlyConfigured`` on errors."""
96
+ raw = getattr(settings, SETTING, {})
97
+ if not isinstance(raw, Mapping):
98
+ raise ImproperlyConfigured(f"{SETTING} must be a dict")
99
+ unknown = set(raw) - set(_DEFAULTS)
100
+ if unknown:
101
+ raise ImproperlyConfigured(
102
+ f"Unknown {SETTING} keys: {', '.join(sorted(unknown))}. "
103
+ f"Valid keys: {', '.join(_DEFAULTS)}."
104
+ )
105
+ values = {**_DEFAULTS, **raw}
106
+
107
+ mode = values["MODE"]
108
+ if mode not in MODES:
109
+ raise ImproperlyConfigured(f"{SETTING}['MODE'] must be one of {MODES}, got {mode!r}")
110
+
111
+ policies: dict[str, Policy] = dict(BUILTIN_POLICIES)
112
+ for name, policy in dict(values["POLICIES"]).items():
113
+ if not isinstance(policy, Policy):
114
+ raise ImproperlyConfigured(
115
+ f"{SETTING}['POLICIES'][{name!r}] must be a Policy, got {type(policy).__name__}"
116
+ )
117
+ policies[name] = policy
118
+
119
+ default_ref = values["DEFAULT_POLICY"]
120
+ default_policy = None if default_ref is None else _resolve(default_ref, policies)
121
+ view_policies = tuple(
122
+ (pattern, None if ref is None else _resolve(ref, policies))
123
+ for pattern, ref in dict(values["VIEW_POLICIES"]).items()
124
+ )
125
+
126
+ handler = values["REJECTION_HANDLER"]
127
+ if isinstance(handler, str):
128
+ try:
129
+ handler = import_string(handler)
130
+ except ImportError as exc:
131
+ raise ImproperlyConfigured(f"Cannot import REJECTION_HANDLER: {exc}") from exc
132
+ if not callable(handler):
133
+ raise ImproperlyConfigured(f"{SETTING}['REJECTION_HANDLER'] must be callable")
134
+
135
+ max_upload = values["MAX_UPLOAD_SIZE"]
136
+ if max_upload is not None and (not isinstance(max_upload, int) or max_upload <= 0):
137
+ raise ImproperlyConfigured(f"{SETTING}['MAX_UPLOAD_SIZE'] must be a positive int")
138
+
139
+ return Config(
140
+ mode=mode,
141
+ default_policy=default_policy,
142
+ policies=MappingProxyType(policies),
143
+ view_policies=view_policies,
144
+ rejection_handler=handler,
145
+ expose_details=bool(values["EXPOSE_DETAILS"]),
146
+ normalize_content_type=bool(values["NORMALIZE_CONTENT_TYPE"]),
147
+ max_upload_size=max_upload,
148
+ messages=MappingProxyType({str(k): v for k, v in dict(values["MESSAGES"]).items()}),
149
+ on_reject=_check_on_reject(values["ON_REJECT"]),
150
+ upload_handlers=_check_upload_handlers(values["UPLOAD_HANDLERS"]),
151
+ )
152
+
153
+
154
+ def _check_on_reject(value: Any) -> str:
155
+ if value not in ON_REJECT:
156
+ raise ImproperlyConfigured(f"{SETTING}['ON_REJECT'] must be one of {ON_REJECT}")
157
+ return value
158
+
159
+
160
+ def _check_upload_handlers(paths: Any) -> tuple[str, ...]:
161
+ from django.core.files.uploadhandler import FileUploadHandler
162
+
163
+ if isinstance(paths, str) or not paths:
164
+ raise ImproperlyConfigured(f"{SETTING}['UPLOAD_HANDLERS'] must be a non-empty list")
165
+ for path in paths:
166
+ if path == _OWN_HANDLER:
167
+ raise ImproperlyConfigured(
168
+ f"{SETTING}['UPLOAD_HANDLERS'] lists the handlers ValidatingUploadHandler "
169
+ "delegates to; it must not contain ValidatingUploadHandler itself"
170
+ )
171
+ try:
172
+ handler = import_string(path)
173
+ except ImportError as exc:
174
+ raise ImproperlyConfigured(f"Cannot import upload handler {path!r}: {exc}") from exc
175
+ if not (isinstance(handler, type) and issubclass(handler, FileUploadHandler)):
176
+ raise ImproperlyConfigured(f"{path!r} is not a FileUploadHandler subclass")
177
+ return tuple(paths)
178
+
179
+
180
+ @functools.cache
181
+ def get_config() -> Config:
182
+ """The cached configuration (rebuilt when the setting changes, e.g. in tests)."""
183
+ return load_config()
184
+
185
+
186
+ @receiver(setting_changed)
187
+ def _reset_config(*, setting: str, **_: Any) -> None:
188
+ if setting == SETTING:
189
+ get_config.cache_clear()
@@ -0,0 +1,204 @@
1
+ """Per-view configuration: ``@validate_uploads`` and ``@skip_upload_validation``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ import sys
8
+ from collections.abc import Callable, Mapping
9
+ from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, cast, overload
10
+
11
+ from asgiref.sync import sync_to_async
12
+ from django.views import View
13
+
14
+ from .service import SKIP, USE_DEFAULT, VIEW_ATTR, ViewSpec, rejection_response, validate_request
15
+
16
+ if TYPE_CHECKING:
17
+ from django.http import HttpRequest, HttpResponse
18
+
19
+ from .conf import PolicyRef
20
+ from .handlers import ValidatingUploadHandler
21
+ from .service import UploadValidation
22
+
23
+ __all__ = ["skip_upload_validation", "validate_uploads"]
24
+
25
+ if sys.version_info >= (3, 12):
26
+ from inspect import iscoroutinefunction
27
+ else: # asgiref also recognises markcoroutinefunction() on Python 3.11
28
+ from asgiref.sync import iscoroutinefunction
29
+
30
+ V = TypeVar("V", bound=Callable[..., Any])
31
+ RejectHandler = Callable[["HttpRequest", "UploadValidation"], "HttpResponse"]
32
+
33
+
34
+ class ViewDecorator(Protocol):
35
+ """What ``validate_uploads(...)`` returns: preserves the decorated view's type."""
36
+
37
+ def __call__(self, view: V, /) -> V: ...
38
+
39
+
40
+ def _check(request: HttpRequest, spec: ViewSpec) -> HttpResponse | None:
41
+ """Validate the request unless ``ValidatingUploadHandler`` will -> rejection or None.
42
+
43
+ With the handler installed nothing needs to happen up front: it reads this view's
44
+ spec and validates whenever the body is parsed (by the view, a form or DRF). A
45
+ multipart POST that was already parsed (before routing), or that Django will parse
46
+ without the handler, is validated here. Any other body without the handler (DRF's
47
+ PUT/PATCH and raw file uploads, which Django never parses) gets a handler for this
48
+ request, so it is validated while whatever parser reads it.
49
+ """
50
+ if spec.mode == "off":
51
+ return None
52
+ # DRF's Request (``@api_view``) wraps the HttpRequest; its own ``_files`` says nothing
53
+ # about whether Django parsed the body.
54
+ request = getattr(request, "_request", request)
55
+ parsed = hasattr(request, "_files")
56
+ handler = _installed_handler(request)
57
+ if handler is not None and not parsed:
58
+ # Routing may not see this decorator (DRF, method_decorator): tell the handler.
59
+ handler.use_spec(spec)
60
+ if request.method == "POST" and request.content_type == "multipart/form-data":
61
+ if parsed or handler is None:
62
+ return rejection_response(request, validate_request(request, spec), spec)
63
+ elif not parsed and handler is None:
64
+ _install_handler(request, spec)
65
+ return None
66
+
67
+
68
+ def _installed_handler(request: HttpRequest) -> ValidatingUploadHandler | None:
69
+ from .handlers import ValidatingUploadHandler
70
+
71
+ for handler in request.upload_handlers:
72
+ if isinstance(handler, ValidatingUploadHandler):
73
+ return handler
74
+ return None
75
+
76
+
77
+ def _install_handler(request: HttpRequest, spec: ViewSpec) -> None:
78
+ from .handlers import ValidatingUploadHandler
79
+
80
+ inner = list(request.upload_handlers)
81
+ request.upload_handlers = [ValidatingUploadHandler(request, inner=inner, spec=spec)]
82
+
83
+
84
+ async def _acheck(request: HttpRequest, spec: ViewSpec) -> HttpResponse | None:
85
+ # Parsing the body and running checks is blocking work: keep it off the event loop.
86
+ return await sync_to_async(_check)(request, spec)
87
+
88
+
89
+ def _wrap_function(view: Callable[..., Any], spec: ViewSpec) -> Callable[..., Any]:
90
+ if iscoroutinefunction(view): # ty: ignore[deprecated] - asgiref branch on 3.11
91
+
92
+ @functools.wraps(view)
93
+ async def async_view(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
94
+ if (response := await _acheck(request, spec)) is not None:
95
+ return response
96
+ return await view(request, *args, **kwargs)
97
+
98
+ wrapped: Callable[..., Any] = async_view
99
+ else:
100
+
101
+ @functools.wraps(view)
102
+ def sync_view(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
103
+ if (response := _check(request, spec)) is not None:
104
+ return response
105
+ return view(request, *args, **kwargs)
106
+
107
+ wrapped = sync_view
108
+ setattr(wrapped, VIEW_ATTR, spec)
109
+ return wrapped
110
+
111
+
112
+ def _wrap_class(cls: type[View], spec: ViewSpec) -> type[View]:
113
+ dispatch = cls.dispatch
114
+ try:
115
+ is_async = bool(cls.view_is_async)
116
+ except Exception:
117
+ is_async = False
118
+
119
+ if is_async:
120
+
121
+ @functools.wraps(dispatch)
122
+ async def async_dispatch(self: Any, request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
123
+ if (response := await _acheck(request, spec)) is not None:
124
+ return response
125
+ # View.dispatch returns the handler's coroutine for async views.
126
+ response = dispatch(self, request, *args, **kwargs)
127
+ return await response if inspect.isawaitable(response) else response
128
+
129
+ cls.dispatch = async_dispatch # ty: ignore[invalid-assignment]
130
+ else:
131
+
132
+ @functools.wraps(dispatch)
133
+ def sync_dispatch(self: Any, request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
134
+ if (response := _check(request, spec)) is not None:
135
+ return response
136
+ return dispatch(self, request, *args, **kwargs)
137
+
138
+ cls.dispatch = sync_dispatch # ty: ignore[invalid-assignment]
139
+ setattr(cls, VIEW_ATTR, spec)
140
+ return cls
141
+
142
+
143
+ def _apply(view: V, spec: ViewSpec) -> V:
144
+ if isinstance(view, type):
145
+ if not issubclass(view, View):
146
+ raise TypeError(f"{view.__name__} is not a Django class-based view")
147
+ return cast("V", _wrap_class(view, spec))
148
+ return cast("V", _wrap_function(view, spec))
149
+
150
+
151
+ @overload
152
+ def validate_uploads(policy: V, /) -> V: ...
153
+ @overload
154
+ def validate_uploads(
155
+ policy: PolicyRef | None = ...,
156
+ /,
157
+ *,
158
+ fields: Mapping[str, PolicyRef | None] | None = ...,
159
+ mode: str | None = ...,
160
+ on_reject: RejectHandler | Literal["reject", "field"] | None = ...,
161
+ ) -> ViewDecorator: ...
162
+ def validate_uploads(
163
+ policy: Any = USE_DEFAULT,
164
+ /,
165
+ *,
166
+ fields: Mapping[str, PolicyRef | None] | None = None,
167
+ mode: str | None = None,
168
+ on_reject: RejectHandler | Literal["reject", "field"] | None = None,
169
+ ) -> Any:
170
+ """Validate the files uploaded to a view (function or class-based, sync or async).
171
+
172
+ ``policy`` is a :class:`~uploadshield.Policy`, a name from
173
+ ``UPLOADSHIELD["POLICIES"]``/the presets, or a dotted path; ``fields``
174
+ overrides it per form field (``None`` skips a field). ``mode`` overrides the
175
+ global ``MODE``. ``on_reject`` decides what a refusal looks like:
176
+
177
+ * ``"reject"`` (default ``ON_REJECT``): the request is answered with 400/413/415;
178
+ * ``"field"``: the file is replaced by an empty placeholder and the view runs, so
179
+ the form reports the error next to the field;
180
+ * a callable ``(request, validation) -> HttpResponse``, e.g.
181
+ :func:`~uploadshield.django.redirect_with_messages`.
182
+
183
+ ``ValidatingUploadHandler`` reads these settings while the body streams in. Without
184
+ the handler, the decorator validates ``request.FILES`` itself before calling the
185
+ view, so it also works on its own.
186
+
187
+ ::
188
+
189
+ @validate_uploads("images")
190
+ def avatar(request): ...
191
+
192
+ @validate_uploads(fields={"cv": "pdf", "photo": "sanitized_images"})
193
+ class ApplicationView(FormView): ...
194
+ """
195
+ if callable(policy): # bare ``@validate_uploads``
196
+ return _apply(policy, ViewSpec())
197
+ spec = ViewSpec(policy=policy, fields=fields or {}, mode=mode, on_reject=on_reject)
198
+ return functools.partial(_apply, spec=spec)
199
+
200
+
201
+ def skip_upload_validation(view: V) -> V:
202
+ """Exempt a view from upload validation (like ``csrf_exempt``)."""
203
+ setattr(view, VIEW_ATTR, SKIP)
204
+ return view
@@ -0,0 +1,39 @@
1
+ """The exception raised when an upload is refused."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from django.core.exceptions import SuspiciousOperation
8
+ from django.http.multipartparser import MultiPartParserError
9
+
10
+ if TYPE_CHECKING:
11
+ from .service import UploadValidation
12
+
13
+ __all__ = ["UploadRejected"]
14
+
15
+
16
+ class UploadRejected(MultiPartParserError, SuspiciousOperation): # noqa: N818 - Django naming
17
+ """An uploaded file violated its policy.
18
+
19
+ Raised by ``ValidatingUploadHandler`` while Django parses the request body, and by
20
+ ``ValidatingStorage`` on ``save()``. The base classes are chosen for Django's own
21
+ handling:
22
+
23
+ * ``MultiPartParserError``: Django answers with ``handler400`` (also when
24
+ ``DEBUG=True``) and marks the body as unparseable, so code that catches the
25
+ exception and reads ``request.FILES`` again gets nothing. DRF turns it into its
26
+ standard 400 ``ParseError``.
27
+ * ``SuspiciousOperation``: like Django's ``TooManyFilesSent``, a refused upload is
28
+ a security event.
29
+
30
+ Point ``handler400`` at ``uploadshield.django.views.bad_request`` for
31
+ 413/415 status codes and JSON/HTML bodies.
32
+ """
33
+
34
+ def __init__(self, validation: UploadValidation) -> None:
35
+ self.validation = validation
36
+ summary = "; ".join(
37
+ f"{o.result.name!r} ({', '.join(o.result.codes)})" for o in validation.rejected
38
+ )
39
+ super().__init__(f"Upload rejected: {summary or 'no details'}")
@@ -0,0 +1,40 @@
1
+ """A form field that validates *and* sanitizes uploads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from django import forms
8
+ from django.core.files.uploadedfile import UploadedFile
9
+
10
+ from .conf import PolicyRef, get_config
11
+ from .service import build_replacement, field_rejection, validate_file
12
+
13
+ __all__ = ["ValidatedFileField"]
14
+
15
+
16
+ class ValidatedFileField(forms.FileField):
17
+ """``forms.FileField`` that runs a policy and returns the sanitized file.
18
+
19
+ Errors appear next to the field like any other form error, which is the best
20
+ experience for HTML forms. ``cleaned_data`` holds the sanitized upload (e.g. a
21
+ re-encoded image under a random name) with ``content_type`` set from the
22
+ detected type, never from the client.
23
+ """
24
+
25
+ def __init__(self, *args: Any, policy: PolicyRef = "default", **kwargs: Any) -> None:
26
+ super().__init__(*args, **kwargs)
27
+ self.policy = policy
28
+
29
+ def clean(self, data: Any, initial: Any = None) -> Any:
30
+ value = super().clean(data, initial)
31
+ if not isinstance(value, UploadedFile):
32
+ return value # empty, cleared, or the initial (already stored) file
33
+ config = get_config()
34
+ policy = config.resolve(self.policy)
35
+ result = validate_file(value, policy)
36
+ if result is None:
37
+ return value
38
+ if not result.ok:
39
+ raise field_rejection(result, policy, config)
40
+ return build_replacement(value, result, policy)