uploadshield 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uploadshield/__init__.py +49 -0
- uploadshield/__main__.py +17 -0
- uploadshield/checks/__init__.py +65 -0
- uploadshield/checks/archive.py +316 -0
- uploadshield/checks/basic.py +476 -0
- uploadshield/checks/image.py +107 -0
- uploadshield/checks/magika.py +129 -0
- uploadshield/checks/markup.py +181 -0
- uploadshield/checks/office.py +174 -0
- uploadshield/checks/pdf.py +277 -0
- uploadshield/checks/scanners.py +205 -0
- uploadshield/checks/xml.py +123 -0
- uploadshield/cli/__init__.py +276 -0
- uploadshield/cli/_commands.py +401 -0
- uploadshield/cli/_json.py +80 -0
- uploadshield/cli/_render.py +319 -0
- uploadshield/cli/_scan.py +106 -0
- uploadshield/cli/_style.py +95 -0
- uploadshield/cli/_verdicts.py +363 -0
- uploadshield/cli/_walk.py +159 -0
- uploadshield/core/__init__.py +4 -0
- uploadshield/core/_deconstruct.py +39 -0
- uploadshield/core/check.py +79 -0
- uploadshield/core/containers.py +131 -0
- uploadshield/core/result.py +63 -0
- uploadshield/core/sniffing.py +432 -0
- uploadshield/core/types.py +319 -0
- uploadshield/core/upload.py +192 -0
- uploadshield/core/violations.py +79 -0
- uploadshield/django/__init__.py +36 -0
- uploadshield/django/apps.py +13 -0
- uploadshield/django/checks.py +148 -0
- uploadshield/django/cli.py +36 -0
- uploadshield/django/conf.py +189 -0
- uploadshield/django/decorators.py +204 -0
- uploadshield/django/exceptions.py +39 -0
- uploadshield/django/forms.py +40 -0
- uploadshield/django/handlers.py +322 -0
- uploadshield/django/responses.py +92 -0
- uploadshield/django/service.py +473 -0
- uploadshield/django/signals.py +16 -0
- uploadshield/django/storage.py +204 -0
- uploadshield/django/templates/uploadshield/rejected.html +15 -0
- uploadshield/django/validators.py +48 -0
- uploadshield/django/views.py +40 -0
- uploadshield/engine.py +230 -0
- uploadshield/fastapi/__init__.py +23 -0
- uploadshield/fastapi/shield.py +299 -0
- uploadshield/flask/__init__.py +42 -0
- uploadshield/flask/core.py +359 -0
- uploadshield/flask/decorators.py +69 -0
- uploadshield/flask/wtforms.py +58 -0
- uploadshield/policy.py +137 -0
- uploadshield/presets.py +65 -0
- uploadshield/py.typed +0 -0
- uploadshield/sanitizers.py +132 -0
- uploadshield/web.py +218 -0
- uploadshield-2.0.0.dist-info/METADATA +239 -0
- uploadshield-2.0.0.dist-info/RECORD +62 -0
- uploadshield-2.0.0.dist-info/WHEEL +4 -0
- uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
- uploadshield-2.0.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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)
|