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,15 @@
|
|
|
1
|
+
{% load i18n %}<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><title>{% translate "Upload rejected" %}</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<h1>{{ rejection.message }}</h1>
|
|
6
|
+
<ul>
|
|
7
|
+
{% for file in rejection.files %}
|
|
8
|
+
<li><strong>{{ file.name }}</strong>
|
|
9
|
+
<ul>{% for error in file.errors %}<li>{{ error.message }}</li>{% endfor %}</ul>
|
|
10
|
+
</li>
|
|
11
|
+
{% endfor %}
|
|
12
|
+
</ul>
|
|
13
|
+
<p><a href="javascript:history.back()">{% translate "Go back" %}</a></p>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Field validators for models, forms and DRF serializers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.utils.deconstruct import deconstructible
|
|
8
|
+
|
|
9
|
+
from .conf import PolicyRef, get_config
|
|
10
|
+
from .service import field_rejection, validate_file
|
|
11
|
+
|
|
12
|
+
__all__ = ["FileValidator"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@deconstructible(path="uploadshield.django.FileValidator")
|
|
16
|
+
class FileValidator:
|
|
17
|
+
"""Validate a file field against a policy::
|
|
18
|
+
|
|
19
|
+
avatar = models.ImageField(validators=[FileValidator("images")])
|
|
20
|
+
cv = forms.FileField(validators=[FileValidator(PDF.replace(max_size=5 * MB))])
|
|
21
|
+
|
|
22
|
+
Validators always enforce, independent of the global ``MODE``: adding one is an
|
|
23
|
+
explicit decision for that field. They cannot replace the file; use
|
|
24
|
+
:class:`~uploadshield.django.forms.ValidatedFileField` or a decorator for
|
|
25
|
+
sanitization. Files already in storage (re-saving a model) are not re-checked.
|
|
26
|
+
|
|
27
|
+
Prefer policy *names* in model fields: they keep migrations short and let you
|
|
28
|
+
change the policy in settings without a new migration.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, policy: PolicyRef = "default") -> None:
|
|
32
|
+
self.policy = policy
|
|
33
|
+
|
|
34
|
+
def __call__(self, value: Any) -> None:
|
|
35
|
+
config = get_config()
|
|
36
|
+
policy = config.resolve(self.policy)
|
|
37
|
+
result = validate_file(value, policy, sanitize=False)
|
|
38
|
+
if result is not None and not result.ok:
|
|
39
|
+
raise field_rejection(result, policy, config)
|
|
40
|
+
|
|
41
|
+
def __eq__(self, other: object) -> bool:
|
|
42
|
+
return isinstance(other, FileValidator) and self.policy == other.policy
|
|
43
|
+
|
|
44
|
+
def __hash__(self) -> int:
|
|
45
|
+
return hash(self.policy)
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return f"FileValidator({self.policy!r})"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Error views.
|
|
2
|
+
|
|
3
|
+
Point Django's ``handler400`` at :func:`bad_request` in your root URLconf::
|
|
4
|
+
|
|
5
|
+
handler400 = "uploadshield.django.views.bad_request"
|
|
6
|
+
|
|
7
|
+
Rejected uploads then get 413/415/400 responses with a JSON or HTML body (or a view's
|
|
8
|
+
own ``on_reject`` handler); every other bad request falls through to Django's default.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from django.views import defaults
|
|
16
|
+
|
|
17
|
+
from .conf import get_config
|
|
18
|
+
from .exceptions import UploadRejected
|
|
19
|
+
from .service import get_view_spec
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from django.http import HttpRequest, HttpResponse
|
|
23
|
+
|
|
24
|
+
__all__ = ["bad_request"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bad_request(request: HttpRequest, exception: Exception | None = None) -> HttpResponse:
|
|
28
|
+
if not isinstance(exception, UploadRejected):
|
|
29
|
+
return defaults.bad_request(request, exception) # ty: ignore[invalid-argument-type]
|
|
30
|
+
match = getattr(request, "resolver_match", None)
|
|
31
|
+
spec = get_view_spec(match.func, request.method) if match is not None else None
|
|
32
|
+
on_reject = spec.on_reject if spec is not None else None
|
|
33
|
+
if on_reject is not None and not isinstance(on_reject, str):
|
|
34
|
+
response = on_reject(request, exception.validation)
|
|
35
|
+
else:
|
|
36
|
+
response = get_config().rejection_handler(request, exception.validation)
|
|
37
|
+
# The rejection was already logged with full details on the "uploadshield"
|
|
38
|
+
# logger; this stops Django from logging it again with a traceback.
|
|
39
|
+
setattr(response, "_has_been_logged", True) # noqa: B010 - private Django flag
|
|
40
|
+
return response
|
uploadshield/engine.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""The validation engine: run a policy against one upload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .core.result import SanitizedFile, ValidationResult
|
|
10
|
+
from .core.upload import ContentTooLargeError, InspectedFile, Upload
|
|
11
|
+
from .core.violations import Code, Violation
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Collection
|
|
15
|
+
|
|
16
|
+
from .core.check import Check, Stage
|
|
17
|
+
from .core.types import FileType
|
|
18
|
+
from .policy import Policy
|
|
19
|
+
|
|
20
|
+
__all__ = ["SanitizationError", "preflight", "validate"]
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("uploadshield")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SanitizationError(Exception):
|
|
26
|
+
"""A sanitizer produced output that does not match the validated file."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate(upload: Upload, policy: Policy, *, sanitize: bool = True) -> ValidationResult:
|
|
30
|
+
"""Validate ``upload`` against ``policy`` and, if it passes, sanitize it.
|
|
31
|
+
|
|
32
|
+
Never raises for bad input: crashing checks and sanitizers are reported as
|
|
33
|
+
violations (fail closed), so a parser bug cannot become a bypass.
|
|
34
|
+
"""
|
|
35
|
+
file = InspectedFile(upload, policy.sniffer)
|
|
36
|
+
try:
|
|
37
|
+
return _validate(file, policy, sanitize=sanitize)
|
|
38
|
+
finally:
|
|
39
|
+
file.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate(file: InspectedFile, policy: Policy, *, sanitize: bool) -> ValidationResult:
|
|
43
|
+
violations: list[Violation] = []
|
|
44
|
+
checks_run: list[str] = []
|
|
45
|
+
|
|
46
|
+
for check in policy.pipeline:
|
|
47
|
+
found = _run_check(check, file)
|
|
48
|
+
if found is None:
|
|
49
|
+
continue
|
|
50
|
+
checks_run.append(check.name)
|
|
51
|
+
violations.extend(found)
|
|
52
|
+
if found and policy.fail_fast:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
sanitized: SanitizedFile | None = None
|
|
56
|
+
if not violations and sanitize and policy.sanitizers:
|
|
57
|
+
try:
|
|
58
|
+
sanitized = _sanitize(file, policy)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.exception("Sanitizing %r failed", file.name)
|
|
61
|
+
violations.append(
|
|
62
|
+
Violation(
|
|
63
|
+
code=Code.SANITIZATION_FAILED,
|
|
64
|
+
message="The file could not be processed.",
|
|
65
|
+
check="sanitizer",
|
|
66
|
+
details={"error": f"{type(exc).__name__}: {exc}"},
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
sanitized is not None
|
|
72
|
+
and sanitized.name != file.name
|
|
73
|
+
and (found := _recheck_name(sanitized.name, file, policy))
|
|
74
|
+
):
|
|
75
|
+
violations.extend(found)
|
|
76
|
+
sanitized = None
|
|
77
|
+
if (
|
|
78
|
+
sanitized is not None
|
|
79
|
+
and sanitized.data is not None
|
|
80
|
+
and (found := _recheck(sanitized.data, sanitized.name, file, policy))
|
|
81
|
+
):
|
|
82
|
+
violations.extend(found)
|
|
83
|
+
sanitized = None
|
|
84
|
+
|
|
85
|
+
return ValidationResult(
|
|
86
|
+
name=file.name,
|
|
87
|
+
field=file.field,
|
|
88
|
+
size=file.size,
|
|
89
|
+
file_type=_safe_file_type(file),
|
|
90
|
+
declared_type=file.declared_type,
|
|
91
|
+
violations=tuple(violations),
|
|
92
|
+
checks_run=tuple(checks_run),
|
|
93
|
+
sanitized=sanitized,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def preflight(
|
|
98
|
+
policy: Policy,
|
|
99
|
+
*,
|
|
100
|
+
stages: Collection[Stage],
|
|
101
|
+
name: str,
|
|
102
|
+
head: bytes = b"",
|
|
103
|
+
size: int = 0,
|
|
104
|
+
declared_type: str | None = None,
|
|
105
|
+
field: str | None = None,
|
|
106
|
+
) -> ValidationResult:
|
|
107
|
+
"""Run the checks of ``stages`` against an upload that is still arriving.
|
|
108
|
+
|
|
109
|
+
``head`` holds the first bytes and ``size`` the bytes received so far. Type checks
|
|
110
|
+
are skipped for container formats (ZIP, OLE2): telling a DOCX from a macro-enabled
|
|
111
|
+
DOCM needs the complete file, so they are decided by :func:`validate` later.
|
|
112
|
+
"""
|
|
113
|
+
file = InspectedFile(Upload(io.BytesIO(head), name, size, declared_type, field), policy.sniffer)
|
|
114
|
+
wanted = set(stages)
|
|
115
|
+
if "type" in wanted:
|
|
116
|
+
detected = _safe_file_type(file)
|
|
117
|
+
if detected is not None and detected.container is not None:
|
|
118
|
+
wanted.discard("type")
|
|
119
|
+
violations: list[Violation] = []
|
|
120
|
+
checks_run: list[str] = []
|
|
121
|
+
for check in policy.pipeline:
|
|
122
|
+
if check.stage not in wanted:
|
|
123
|
+
continue
|
|
124
|
+
found = _run_check(check, file)
|
|
125
|
+
if found is None:
|
|
126
|
+
continue
|
|
127
|
+
checks_run.append(check.name)
|
|
128
|
+
violations.extend(found)
|
|
129
|
+
if found and policy.fail_fast:
|
|
130
|
+
break
|
|
131
|
+
return ValidationResult(
|
|
132
|
+
name=file.name,
|
|
133
|
+
field=field,
|
|
134
|
+
size=size,
|
|
135
|
+
file_type=_safe_file_type(file) if "type" in wanted else None,
|
|
136
|
+
declared_type=declared_type,
|
|
137
|
+
violations=tuple(violations),
|
|
138
|
+
checks_run=tuple(checks_run),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_check(check: Check, file: InspectedFile) -> list[Violation] | None:
|
|
143
|
+
"""Run one check; ``None`` means "not applicable"."""
|
|
144
|
+
try:
|
|
145
|
+
if not check.applies(file):
|
|
146
|
+
return None
|
|
147
|
+
return list(check.run(file))
|
|
148
|
+
except ContentTooLargeError as exc:
|
|
149
|
+
logger.warning("Check %r could not inspect %r: %s", check.name, file.name, exc)
|
|
150
|
+
error = str(exc)
|
|
151
|
+
reason = "too_large"
|
|
152
|
+
message = "The file is too large to be verified."
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.exception("Check %r crashed on %r", check.name, file.name)
|
|
155
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
156
|
+
reason = "crashed"
|
|
157
|
+
message = "The file could not be verified."
|
|
158
|
+
details = {"error": error, "reason": reason}
|
|
159
|
+
return [Violation(code=Code.CHECK_FAILED, message=message, check=check.name, details=details)]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _recheck_name(name: str, file: InspectedFile, policy: Policy) -> list[Violation]:
|
|
163
|
+
"""Names produced by sanitizers must pass the same filename rules as uploaded ones."""
|
|
164
|
+
probe = InspectedFile(Upload(io.BytesIO(b""), name, 0, None, file.field), policy.sniffer)
|
|
165
|
+
return [
|
|
166
|
+
Violation(v.code, v.message, v.check, {**v.details, "in_sanitized_output": True})
|
|
167
|
+
for v in _run_check(policy.filename, probe) or []
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _recheck(data: bytes, name: str, file: InspectedFile, policy: Policy) -> list[Violation]:
|
|
172
|
+
"""Run the content checks on what the sanitizers produced.
|
|
173
|
+
|
|
174
|
+
Re-encoding must not *create* what the checks refuse: payloads can be crafted to
|
|
175
|
+
appear only after an image library compresses the pixels again.
|
|
176
|
+
"""
|
|
177
|
+
output = InspectedFile(
|
|
178
|
+
Upload(io.BytesIO(data), name, len(data), file.declared_type, file.field), policy.sniffer
|
|
179
|
+
)
|
|
180
|
+
found: list[Violation] = []
|
|
181
|
+
for check in policy.content_checks:
|
|
182
|
+
for violation in _run_check(check, output) or []:
|
|
183
|
+
details = {**violation.details, "in_sanitized_output": True}
|
|
184
|
+
found.append(Violation(violation.code, violation.message, violation.check, details))
|
|
185
|
+
return found
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _sanitize(file: InspectedFile, policy: Policy) -> SanitizedFile | None:
|
|
189
|
+
current = file
|
|
190
|
+
data: bytes | None = None
|
|
191
|
+
applied: list[str] = []
|
|
192
|
+
for sanitizer in policy.sanitizers:
|
|
193
|
+
if not sanitizer.applies(current):
|
|
194
|
+
continue
|
|
195
|
+
replacement = sanitizer.sanitize(current)
|
|
196
|
+
applied.append(sanitizer.name)
|
|
197
|
+
if replacement.data is None and replacement.name is None:
|
|
198
|
+
continue
|
|
199
|
+
if replacement.data is not None:
|
|
200
|
+
data = replacement.data
|
|
201
|
+
stream = io.BytesIO(data) if data is not None else current.stream
|
|
202
|
+
current = InspectedFile(
|
|
203
|
+
Upload(
|
|
204
|
+
stream=stream,
|
|
205
|
+
name=replacement.name or current.name,
|
|
206
|
+
size=len(data) if data is not None else current.size,
|
|
207
|
+
declared_type=current.declared_type,
|
|
208
|
+
field=current.field,
|
|
209
|
+
),
|
|
210
|
+
policy.sniffer,
|
|
211
|
+
)
|
|
212
|
+
if not applied:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
original, result = file.file_type, current.file_type
|
|
216
|
+
if result is None or (original is not None and result.mime != original.mime):
|
|
217
|
+
raise SanitizationError(
|
|
218
|
+
f"sanitized output is {result.mime if result else 'unidentifiable'}, "
|
|
219
|
+
f"expected {original.mime if original else 'unknown'}"
|
|
220
|
+
)
|
|
221
|
+
return SanitizedFile(
|
|
222
|
+
name=current.name, content_type=result.mime, data=data, applied=tuple(applied)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _safe_file_type(file: InspectedFile) -> FileType | None:
|
|
227
|
+
try:
|
|
228
|
+
return file.file_type
|
|
229
|
+
except Exception:
|
|
230
|
+
return None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""FastAPI integration: validated uploads as dependencies.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, FastAPI, UploadFile
|
|
6
|
+
from uploadshield.fastapi import UploadShield
|
|
7
|
+
|
|
8
|
+
shield = UploadShield()
|
|
9
|
+
app = FastAPI(dependencies=[Depends(shield.all_files())]) # optional safety net
|
|
10
|
+
|
|
11
|
+
@app.post("/avatar")
|
|
12
|
+
async def avatar(file: Annotated[UploadFile, Depends(shield.file("sanitized_images"))]):
|
|
13
|
+
... # `file` is validated and sanitized; its content type comes from its bytes
|
|
14
|
+
|
|
15
|
+
FastAPI parses the request body before dependencies run, so files are checked once
|
|
16
|
+
they have been received (there is no per-file streaming hook as in Django). Limit
|
|
17
|
+
request sizes at the server or proxy. Validation runs in Starlette's thread pool, so
|
|
18
|
+
the event loop is never blocked.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .shield import UploadShield
|
|
22
|
+
|
|
23
|
+
__all__ = ["UploadShield"]
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""The :class:`UploadShield` dependency factory for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import io
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
8
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
9
|
+
|
|
10
|
+
from fastapi import File, HTTPException, Request, UploadFile, params
|
|
11
|
+
from starlette.concurrency import run_in_threadpool
|
|
12
|
+
from starlette.datastructures import Headers
|
|
13
|
+
from starlette.datastructures import UploadFile as StarletteUploadFile
|
|
14
|
+
from starlette.requests import HTTPConnection # noqa: TC002 - FastAPI injects by annotation
|
|
15
|
+
|
|
16
|
+
from uploadshield import web
|
|
17
|
+
from uploadshield.core.upload import Upload
|
|
18
|
+
from uploadshield.engine import validate
|
|
19
|
+
from uploadshield.presets import BUILTIN_POLICIES
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from typing import IO
|
|
23
|
+
|
|
24
|
+
from uploadshield.core.result import ValidationResult
|
|
25
|
+
from uploadshield.policy import Policy
|
|
26
|
+
|
|
27
|
+
__all__ = ["UploadShield"]
|
|
28
|
+
|
|
29
|
+
MODES = ("enforce", "report", "off")
|
|
30
|
+
#: Set on the dependencies :meth:`UploadShield.file`/``files`` build:
|
|
31
|
+
#: ``(form field, whether it takes every file of the field)``.
|
|
32
|
+
FIELD_ATTR = "__uploadshield_field__"
|
|
33
|
+
#: ``(request, [(field, result), ...])``, called for refused files (sync or async).
|
|
34
|
+
RejectedCallback = Callable[[Request, list[tuple[str, "ValidationResult"]]], Awaitable[None] | None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UploadShield:
|
|
38
|
+
"""Builds FastAPI dependencies that validate uploads against policies.
|
|
39
|
+
|
|
40
|
+
``policies`` are added to the built-in presets (``"images"``, ``"pdf"``, ...);
|
|
41
|
+
any dependency accepts a :class:`~uploadshield.Policy`, a registered name, or a
|
|
42
|
+
dotted import path. ``mode`` is ``"enforce"`` (refuse with 400/413/415),
|
|
43
|
+
``"report"`` (log and call ``on_rejected`` only) or ``"off"``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
policies: Mapping[str, Policy] | None = None,
|
|
50
|
+
default_policy: Any = "default",
|
|
51
|
+
mode: str = "enforce",
|
|
52
|
+
expose_details: bool = False,
|
|
53
|
+
messages: Mapping[str, str] | None = None,
|
|
54
|
+
on_rejected: RejectedCallback | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
if mode not in MODES:
|
|
57
|
+
raise ValueError(f"mode must be one of {MODES}, got {mode!r}")
|
|
58
|
+
self.policies: dict[str, Policy] = {**BUILTIN_POLICIES, **(policies or {})}
|
|
59
|
+
self.default_policy = self.resolve(default_policy)
|
|
60
|
+
self.mode = mode
|
|
61
|
+
self.expose_details = expose_details
|
|
62
|
+
self.messages = dict(messages or {})
|
|
63
|
+
self.on_rejected = on_rejected
|
|
64
|
+
|
|
65
|
+
def resolve(self, ref: Any) -> Policy:
|
|
66
|
+
try:
|
|
67
|
+
return web.resolve_policy(ref, self.policies)
|
|
68
|
+
except web.PolicyLookupError as exc:
|
|
69
|
+
raise ValueError(str(exc)) from exc
|
|
70
|
+
|
|
71
|
+
# -- dependency factories ---------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def file(
|
|
74
|
+
self, policy: Any = None, *, alias: str = "file"
|
|
75
|
+
) -> Callable[..., Awaitable[UploadFile]]:
|
|
76
|
+
"""A dependency for one file field; returns the validated file, sanitized in place.
|
|
77
|
+
|
|
78
|
+
The ``UploadFile`` itself is updated (bytes, name, content type), so a route
|
|
79
|
+
that also receives the field directly, or declares the dependency in
|
|
80
|
+
``dependencies=[...]``, sees the sanitized file as well.
|
|
81
|
+
"""
|
|
82
|
+
resolved = self.default_policy if policy is None else self.resolve(policy)
|
|
83
|
+
|
|
84
|
+
async def dependency(request: Request, upload: UploadFile) -> UploadFile:
|
|
85
|
+
results = await self._check(request, [(alias, upload)], resolved, sanitize=True)
|
|
86
|
+
return upload if results is None else _deliver(upload, results[0])
|
|
87
|
+
|
|
88
|
+
return _with_signature(dependency, File(alias=alias), UploadFile, field=(alias, False))
|
|
89
|
+
|
|
90
|
+
def files(
|
|
91
|
+
self, policy: Any = None, *, alias: str = "files"
|
|
92
|
+
) -> Callable[..., Awaitable[list[UploadFile]]]:
|
|
93
|
+
"""A dependency for a multi-file field; returns the files, sanitized in place."""
|
|
94
|
+
resolved = self.default_policy if policy is None else self.resolve(policy)
|
|
95
|
+
|
|
96
|
+
async def dependency(request: Request, upload: list[UploadFile]) -> list[UploadFile]:
|
|
97
|
+
items = [(alias, u) for u in upload]
|
|
98
|
+
results = await self._check(request, items, resolved, sanitize=True)
|
|
99
|
+
if results is None:
|
|
100
|
+
return upload
|
|
101
|
+
return [_deliver(u, r) for u, r in zip(upload, results, strict=True)]
|
|
102
|
+
|
|
103
|
+
return _with_signature(dependency, File(alias=alias), list[UploadFile], field=(alias, True))
|
|
104
|
+
|
|
105
|
+
def all_files(self, policy: Any = None) -> Callable[..., AsyncIterator[None]]:
|
|
106
|
+
"""A safety-net dependency for ``FastAPI(dependencies=...)`` or a router.
|
|
107
|
+
|
|
108
|
+
It validates every file of a multipart request to the routes it is attached to
|
|
109
|
+
against ``policy`` (default: ``default_policy``) and refuses the request if one
|
|
110
|
+
fails. Fields that the endpoint validates itself with :meth:`file`/:meth:`files`
|
|
111
|
+
are left to that policy, as a view's own policy wins over the default in Django
|
|
112
|
+
and Flask. Passing files get the detected ``content_type`` (in place: Starlette's
|
|
113
|
+
form data is immutable); use :meth:`file` for sanitization. A part without a
|
|
114
|
+
filename sent to a field the route declares as ``File()`` is checked too (and
|
|
115
|
+
refused for its missing name): FastAPI would hand its content to a ``bytes``
|
|
116
|
+
parameter. WebSocket routes are left alone, and so are apps mounted inside.
|
|
117
|
+
|
|
118
|
+
A form the safety net parses itself (the endpoint declares no form parameters)
|
|
119
|
+
is closed once the response is done, as FastAPI does with forms it parses.
|
|
120
|
+
"""
|
|
121
|
+
resolved = self.default_policy if policy is None else self.resolve(policy)
|
|
122
|
+
|
|
123
|
+
async def dependency(connection: HTTPConnection) -> AsyncIterator[None]:
|
|
124
|
+
if self.mode == "off" or not (
|
|
125
|
+
isinstance(connection, Request) and _is_multipart(connection)
|
|
126
|
+
):
|
|
127
|
+
yield
|
|
128
|
+
return
|
|
129
|
+
request = connection
|
|
130
|
+
# Starlette caches the parsed form here; set when FastAPI parsed it already.
|
|
131
|
+
owned = getattr(request, "_form", None) is None
|
|
132
|
+
form = await request.form() # cached: the endpoint sees the same files
|
|
133
|
+
try:
|
|
134
|
+
items = _unchecked_files(request, form)
|
|
135
|
+
results = await self._check(request, items, resolved, sanitize=False)
|
|
136
|
+
for (_, upload), result in zip(items, results or [], strict=False):
|
|
137
|
+
if result.ok and result.file_type is not None:
|
|
138
|
+
_set_content_type(upload, result.file_type.mime)
|
|
139
|
+
yield
|
|
140
|
+
finally:
|
|
141
|
+
if owned:
|
|
142
|
+
await request.close() # its temporary files
|
|
143
|
+
|
|
144
|
+
return dependency
|
|
145
|
+
|
|
146
|
+
# -- internals --------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async def _check(
|
|
149
|
+
self,
|
|
150
|
+
request: Request,
|
|
151
|
+
items: list[tuple[str, Any]],
|
|
152
|
+
policy: Policy,
|
|
153
|
+
*,
|
|
154
|
+
sanitize: bool,
|
|
155
|
+
) -> list[ValidationResult] | None:
|
|
156
|
+
"""Validate ``items``; refuse (enforce) or report. ``None`` when mode is off."""
|
|
157
|
+
if self.mode == "off":
|
|
158
|
+
return None
|
|
159
|
+
results = [
|
|
160
|
+
await run_in_threadpool(_validate, upload, policy, field, sanitize=sanitize)
|
|
161
|
+
for field, upload in items
|
|
162
|
+
]
|
|
163
|
+
rejected = [
|
|
164
|
+
(field, result)
|
|
165
|
+
for (field, _), result in zip(items, results, strict=True)
|
|
166
|
+
if not result.ok
|
|
167
|
+
]
|
|
168
|
+
for (field, upload), result in zip(items, results, strict=True):
|
|
169
|
+
if not result.ok:
|
|
170
|
+
digest = await run_in_threadpool(web.sha256_of, upload.file)
|
|
171
|
+
web.log_rejection(
|
|
172
|
+
result,
|
|
173
|
+
field=field,
|
|
174
|
+
policy_name=policy.name,
|
|
175
|
+
mode=self.mode,
|
|
176
|
+
path=request.url.path,
|
|
177
|
+
sha256=digest,
|
|
178
|
+
)
|
|
179
|
+
if rejected:
|
|
180
|
+
if self.on_rejected is not None:
|
|
181
|
+
outcome = self.on_rejected(request, rejected)
|
|
182
|
+
if inspect.isawaitable(outcome):
|
|
183
|
+
await outcome
|
|
184
|
+
if self.mode == "enforce":
|
|
185
|
+
raise HTTPException(
|
|
186
|
+
status_code=web.status_code_for(r for _, r in rejected),
|
|
187
|
+
detail=web.rejection_payload(
|
|
188
|
+
(r for _, r in rejected),
|
|
189
|
+
include_details=self.expose_details,
|
|
190
|
+
messages=self.messages,
|
|
191
|
+
fields=[f for f, _ in rejected],
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
return results
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validate(upload: Any, policy: Policy, field: str, *, sanitize: bool) -> ValidationResult:
|
|
198
|
+
stream = cast("IO[bytes]", upload.file)
|
|
199
|
+
return validate(
|
|
200
|
+
Upload(
|
|
201
|
+
stream=stream,
|
|
202
|
+
name=upload.filename or "",
|
|
203
|
+
size=upload.size,
|
|
204
|
+
declared_type=upload.content_type,
|
|
205
|
+
field=field,
|
|
206
|
+
),
|
|
207
|
+
policy,
|
|
208
|
+
sanitize=sanitize,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _deliver(upload: Any, result: ValidationResult) -> UploadFile:
|
|
213
|
+
"""Sanitize ``upload`` in place: bytes, name and the detected content type.
|
|
214
|
+
|
|
215
|
+
Updating the object (rather than returning a new one) means every reference to it -
|
|
216
|
+
the dependency's return value, the route's own parameter, ``request.form()`` - sees
|
|
217
|
+
the sanitized file.
|
|
218
|
+
"""
|
|
219
|
+
if not result.ok or result.file_type is None:
|
|
220
|
+
return upload # report mode: untouched
|
|
221
|
+
sanitized = result.sanitized
|
|
222
|
+
content_type = result.file_type.mime
|
|
223
|
+
if sanitized is not None:
|
|
224
|
+
upload.filename = sanitized.name
|
|
225
|
+
content_type = sanitized.content_type
|
|
226
|
+
if sanitized.data is not None:
|
|
227
|
+
original = upload.file
|
|
228
|
+
upload.file = io.BytesIO(sanitized.data)
|
|
229
|
+
upload.size = len(sanitized.data)
|
|
230
|
+
original.close() # the form now closes the replacement instead
|
|
231
|
+
_set_content_type(upload, content_type)
|
|
232
|
+
return upload
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _is_multipart(request: Request) -> bool:
|
|
236
|
+
"""Decide "multipart" the way Starlette's parser does: case- and space-insensitive."""
|
|
237
|
+
media_type = request.headers.get("content-type", "").split(";", 1)[0]
|
|
238
|
+
return media_type.strip().lower() == "multipart/form-data"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _unchecked_files(request: Request, form: Any) -> list[tuple[str, Any]]:
|
|
242
|
+
"""The parts the safety net must check: every file no endpoint dependency takes.
|
|
243
|
+
|
|
244
|
+
A field taken by :meth:`UploadShield.file` only exempts the part FastAPI hands it
|
|
245
|
+
(the last one); further parts under the same name are checked here. Parts without
|
|
246
|
+
a filename that a ``File()`` parameter would receive as bytes are checked as files.
|
|
247
|
+
"""
|
|
248
|
+
own, file_fields = _route_fields(request)
|
|
249
|
+
items: list[tuple[str, Any]] = []
|
|
250
|
+
for key, value in form.multi_items():
|
|
251
|
+
if isinstance(value, StarletteUploadFile):
|
|
252
|
+
many = own.get(key)
|
|
253
|
+
if many is True or (many is False and value is form.get(key)):
|
|
254
|
+
continue
|
|
255
|
+
if not web.is_unused_file_input(value.filename, value.file):
|
|
256
|
+
items.append((key, value))
|
|
257
|
+
elif isinstance(value, str) and value and key in file_fields:
|
|
258
|
+
data = value.encode("utf-8", "surrogateescape")
|
|
259
|
+
items.append((key, StarletteUploadFile(io.BytesIO(data), size=len(data))))
|
|
260
|
+
return items
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _set_content_type(upload: StarletteUploadFile, mime: str) -> None:
|
|
264
|
+
"""Replace the client-declared type in place (the safety net cannot swap files)."""
|
|
265
|
+
raw = [(k, v) for k, v in upload.headers.raw if k.lower() != b"content-type"]
|
|
266
|
+
upload.headers = Headers(raw=[*raw, (b"content-type", mime.encode("latin-1"))])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _route_fields(request: Request) -> tuple[dict[str, bool], set[str]]:
|
|
270
|
+
"""``(fields file()/files() dependencies take -> takes all parts, File() fields)``."""
|
|
271
|
+
root = getattr(request.scope.get("route"), "dependant", None)
|
|
272
|
+
own: dict[str, bool] = {}
|
|
273
|
+
file_fields: set[str] = set()
|
|
274
|
+
pending = [root] if root is not None else []
|
|
275
|
+
while pending:
|
|
276
|
+
dependant = pending.pop()
|
|
277
|
+
if (field := getattr(dependant.call, FIELD_ATTR, None)) is not None:
|
|
278
|
+
alias, many = field
|
|
279
|
+
own[alias] = own.get(alias, False) or many
|
|
280
|
+
for body_field in getattr(dependant, "body_params", ()):
|
|
281
|
+
if isinstance(getattr(body_field, "field_info", None), params.File):
|
|
282
|
+
file_fields.add(body_field.alias)
|
|
283
|
+
pending.extend(dependant.dependencies)
|
|
284
|
+
return own, file_fields
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _with_signature(
|
|
288
|
+
dependency: Any, default: Any, annotation: Any, *, field: tuple[str, bool]
|
|
289
|
+
) -> Any:
|
|
290
|
+
"""Give the dependency an ``upload`` parameter bound to the form field ``alias``."""
|
|
291
|
+
kw = inspect.Parameter.KEYWORD_ONLY
|
|
292
|
+
dependency.__signature__ = inspect.Signature(
|
|
293
|
+
[
|
|
294
|
+
inspect.Parameter("request", kw, annotation=Request),
|
|
295
|
+
inspect.Parameter("upload", kw, default=default, annotation=annotation),
|
|
296
|
+
]
|
|
297
|
+
)
|
|
298
|
+
setattr(dependency, FIELD_ATTR, field)
|
|
299
|
+
return dependency
|