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,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
|