uploadshield 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. uploadshield/__init__.py +49 -0
  2. uploadshield/__main__.py +17 -0
  3. uploadshield/checks/__init__.py +65 -0
  4. uploadshield/checks/archive.py +316 -0
  5. uploadshield/checks/basic.py +476 -0
  6. uploadshield/checks/image.py +107 -0
  7. uploadshield/checks/magika.py +129 -0
  8. uploadshield/checks/markup.py +181 -0
  9. uploadshield/checks/office.py +174 -0
  10. uploadshield/checks/pdf.py +277 -0
  11. uploadshield/checks/scanners.py +205 -0
  12. uploadshield/checks/xml.py +123 -0
  13. uploadshield/cli/__init__.py +276 -0
  14. uploadshield/cli/_commands.py +401 -0
  15. uploadshield/cli/_json.py +80 -0
  16. uploadshield/cli/_render.py +319 -0
  17. uploadshield/cli/_scan.py +106 -0
  18. uploadshield/cli/_style.py +95 -0
  19. uploadshield/cli/_verdicts.py +363 -0
  20. uploadshield/cli/_walk.py +159 -0
  21. uploadshield/core/__init__.py +4 -0
  22. uploadshield/core/_deconstruct.py +39 -0
  23. uploadshield/core/check.py +79 -0
  24. uploadshield/core/containers.py +131 -0
  25. uploadshield/core/result.py +63 -0
  26. uploadshield/core/sniffing.py +432 -0
  27. uploadshield/core/types.py +319 -0
  28. uploadshield/core/upload.py +192 -0
  29. uploadshield/core/violations.py +79 -0
  30. uploadshield/django/__init__.py +36 -0
  31. uploadshield/django/apps.py +13 -0
  32. uploadshield/django/checks.py +148 -0
  33. uploadshield/django/cli.py +36 -0
  34. uploadshield/django/conf.py +189 -0
  35. uploadshield/django/decorators.py +204 -0
  36. uploadshield/django/exceptions.py +39 -0
  37. uploadshield/django/forms.py +40 -0
  38. uploadshield/django/handlers.py +322 -0
  39. uploadshield/django/responses.py +92 -0
  40. uploadshield/django/service.py +473 -0
  41. uploadshield/django/signals.py +16 -0
  42. uploadshield/django/storage.py +204 -0
  43. uploadshield/django/templates/uploadshield/rejected.html +15 -0
  44. uploadshield/django/validators.py +48 -0
  45. uploadshield/django/views.py +40 -0
  46. uploadshield/engine.py +230 -0
  47. uploadshield/fastapi/__init__.py +23 -0
  48. uploadshield/fastapi/shield.py +299 -0
  49. uploadshield/flask/__init__.py +42 -0
  50. uploadshield/flask/core.py +359 -0
  51. uploadshield/flask/decorators.py +69 -0
  52. uploadshield/flask/wtforms.py +58 -0
  53. uploadshield/policy.py +137 -0
  54. uploadshield/presets.py +65 -0
  55. uploadshield/py.typed +0 -0
  56. uploadshield/sanitizers.py +132 -0
  57. uploadshield/web.py +218 -0
  58. uploadshield-2.0.0.dist-info/METADATA +239 -0
  59. uploadshield-2.0.0.dist-info/RECORD +62 -0
  60. uploadshield-2.0.0.dist-info/WHEEL +4 -0
  61. uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
  62. uploadshield-2.0.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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