safeuploads 3.0.0__tar.gz → 4.0.0__tar.gz
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.
- {safeuploads-3.0.0 → safeuploads-4.0.0}/CHANGELOG.md +38 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/PKG-INFO +20 -7
- {safeuploads-3.0.0 → safeuploads-4.0.0}/README.md +19 -6
- {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/index.md +19 -6
- {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/security/architecture.md +43 -18
- {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/security/integration-checklist.md +46 -10
- {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/security/threat-model.md +79 -2
- {safeuploads-3.0.0 → safeuploads-4.0.0}/examples/README.md +2 -2
- {safeuploads-3.0.0 → safeuploads-4.0.0}/examples/fastapi_example.py +27 -19
- {safeuploads-3.0.0 → safeuploads-4.0.0}/pyproject.toml +1 -1
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/__init__.py +6 -1
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/config.py +206 -144
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/exceptions.py +48 -4
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/file_validator.py +198 -296
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/utils.py +18 -2
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/__init__.py +4 -2
- safeuploads-4.0.0/safeuploads/validators/image_dimension_validator.py +336 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/mime_validator.py +44 -15
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/signature_validator.py +9 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/conftest.py +18 -29
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/test_fuzz_images.py +52 -1
- safeuploads-4.0.0/tests/images.py +188 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/inspectors/test_content_inspector.py +5 -17
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_config.py +138 -81
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_config_validation.py +122 -22
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_exceptions.py +45 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_file_validator.py +202 -157
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_performance.py +4 -34
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_sync_validation.py +171 -19
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_validate_activity_file.py +3 -2
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_extension_validator.py +10 -8
- safeuploads-4.0.0/tests/validators/test_image_dimension_validator.py +291 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_mime_validator.py +75 -7
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_signature_validator.py +37 -1
- {safeuploads-3.0.0 → safeuploads-4.0.0}/uv.lock +1 -1
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/conventional-commits/SKILL.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/conventional-commits/references/pull-requests.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/debugging/SKILL.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/safeuploads-library/SKILL.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/safeuploads-library/references/code-standards.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/safeuploads-library/references/security.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/safeuploads-library/references/testing.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/skill-authoring/SKILL.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/CODEOWNERS +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/FUNDING.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/copilot-instructions.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/dependabot.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/scripts/check_conventional_commits.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/scripts/smoke_import.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/audit-safeuploads.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/conventional-commits.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/docs.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/fuzz.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/lint-and-test.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/mirror.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/publish-safeuploads.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/test-matrix-safeuploads.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/.gitignore +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/AGENTS.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/CODE_OF_CONDUCT.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/CONTRIBUTING.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/LICENSE.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/SECURITY.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/api.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/rate-limiting.md +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/mkdocs.yml +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/audit.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/enums.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/__init__.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/base.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/content_inspector.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/gzip_inspector.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/zip_inspector.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/protocols.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/py.typed +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/base.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/compression_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/extension_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/filename_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/fit_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/unicode_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/windows_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/xml_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/__init__.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/__init__.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/helpers.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/test_fuzz_config.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/test_fuzz_filenames.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/test_fuzz_zip.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/inspectors/__init__.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/inspectors/test_gzip_inspector.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/inspectors/test_zip_inspector.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_audit.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_enums.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_fastapi_integration.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_utils.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_validate_gzip_file.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/__init__.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_compression_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_filename_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_fit_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_unicode_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_windows_validator.py +0 -0
- {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_xml_validator.py +0 -0
|
@@ -4,6 +4,44 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [4.0.0] - 2026-11-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- WebP image support. `FileSignatureValidator` accepts a RIFF container whose form type at bytes 8-11 is `WEBP` as an image signature; WAV, AVI and other RIFF files are still rejected. `ALLOWED_IMAGE_TYPES` maps `image/webp` to `.webp`.
|
|
12
|
+
- `ValidationResult`, exported from `safeuploads`: a frozen dataclass with the sanitized `filename` and the `mime_type` libmagic detected from the content. A ZIP or gzip that libmagic reports only as `application/octet-stream` (libmagic 5.46 reports every ZIP this way) carries `application/zip` or `application/gzip`, the type its magic signature confirmed. Use `mime_type` for the `Content-Type` of a stored or served copy, and the extension of `filename`, which is checked against `mime_type`, for a stored file's extension, as the integration checklist recommends. `mime_type` alone cannot choose the extension, since GPX and TCX share their XML types.
|
|
13
|
+
- `ErrorCode.EXTENSION_MIME_MISMATCH`, raised with `MimeTypeError` when the content's MIME type is allowed but the filename's extension is not one listed for it. `allowed_mimes` on the error lists the MIME types that extension permits.
|
|
14
|
+
- Image dimension limit against decompression (pixel) bombs. `ImageDimensionValidator`, exported from `safeuploads`, reads width and height from the PNG IHDR chunk, the first JPEG frame header or the first WebP chunk (VP8, VP8L or the VP8X canvas) without decoding pixel data, and rejects an image whose width times height exceeds the new `SecurityLimits.max_image_pixels` (default 89,478,485, Pillow's `Image.MAX_IMAGE_PIXELS`). Configuration validation rejects a limit of 0 or less (`invalid_pixel_limit`) and warns above twice the default (`excessive_pixel_limit`).
|
|
15
|
+
- `ImageDimensionError`, exported from `safeuploads`: a `FileValidationError` with `width`, `height` and `max_pixels`, raised with the new `ErrorCode.IMAGE_DIMENSIONS_EXCEEDED`.
|
|
16
|
+
- `ErrorCode.IMAGE_INVALID_STRUCTURE`, raised with `FileSignatureError` when an image's dimension header is missing, truncated or invalid.
|
|
17
|
+
- `FileSecurityConfig.ALLOWED_IMAGE_TYPES`, `ALLOWED_ZIP_TYPES`, `ALLOWED_ACTIVITY_TYPES` and `ALLOWED_GZIP_TYPES`: read-only mappings from each allowed MIME type to the extensions its content may carry. Configuration validation reports an empty mapping (`empty_mime_set`), a MIME type with no extensions (`empty_extension_set`), and the existing extension format, case and block-list conflict checks on every listed extension.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Breaking:** every `validate_*_file()` method returns a `ValidationResult` instead of `None`, and every `validate_*_file_sync()` method returns a `ValidationResult` instead of the sanitized filename. Replace `name = validator.validate_image_file_sync(f, name)` with `name = validator.validate_image_file_sync(f, name).filename`.
|
|
22
|
+
- **Breaking:** an upload is rejected with `MimeTypeError` (`EXTENSION_MIME_MISMATCH`) when its extension is not one listed for the MIME type detected from its content, for example a PNG or WebP named `photo.jpg`, or a JPEG named `photo.png`. 3.0.0 accepted any allowed extension with any allowed MIME type of the same category. To accept such a pair, list the extension under that MIME type, for example `".jpg"` under `"image/webp"`.
|
|
23
|
+
- **Breaking:** `validate_image_file()` and `validate_image_file_sync()` accept `.webp` uploads with WebP content under the default configuration, which 3.0.0 rejected. A host that cannot handle WebP after validation removes `image/webp` from `ALLOWED_IMAGE_TYPES`.
|
|
24
|
+
- **Breaking:** `validate_image_file()` and `validate_image_file_sync()` reject an image whose header declares more than `max_image_pixels` pixels with `ImageDimensionError` (`IMAGE_DIMENSIONS_EXCEEDED`), and an image whose dimension header is missing or malformed with `FileSignatureError` (`IMAGE_INVALID_STRUCTURE`): a PNG whose first chunk is not IHDR, a JPEG that reaches scan data or its end without a frame header or declares a zero width or height, or a WebP whose first chunk is not VP8, VP8L or VP8X. 3.0.0 accepted any image with a valid signature. To accept larger images, raise `max_image_pixels`.
|
|
25
|
+
- Image uploads are streamed once to a spooled temporary file, like ZIP, activity and gzip uploads, instead of being read once for the size check and again for content analysis. The client's declared `size` is no longer checked before reading: an oversized image is rejected with the same `FileSizeError` (`FILE_TOO_LARGE`) once the bytes read exceed `max_image_size`, and `size` on the error is the byte count read at that point.
|
|
26
|
+
- **Breaking:** FIT uploads get the MIME check the other formats get: a `.fit` file must be detected as `application/octet-stream`. A fake FIT that libmagic reports as text is now rejected with `EXTENSION_MIME_MISMATCH` before the FIT structure check.
|
|
27
|
+
- **Breaking:** `MimeTypeValidator.enforce()` takes an allowed-types mapping instead of a set of MIME types and checks the filename's extension against it.
|
|
28
|
+
- **Breaking:** `image/jpg` is no longer an allowed image MIME type. It is not a registered type and libmagic reports JPEG content as `image/jpeg`.
|
|
29
|
+
|
|
30
|
+
### Removed
|
|
31
|
+
|
|
32
|
+
- **Breaking:** `FileSecurityConfig.ALLOWED_IMAGE_MIMES`, `ALLOWED_ZIP_MIMES`, `ALLOWED_ACTIVITY_MIMES`, `ALLOWED_GZIP_MIMES`, `ALLOWED_IMAGE_EXTENSIONS`, `ALLOWED_ZIP_EXTENSIONS`, `ALLOWED_ACTIVITY_EXTENSIONS` and `ALLOWED_GZIP_EXTENSIONS`. Each pair is replaced by one `ALLOWED_*_TYPES` mapping, so a MIME type and its extensions can no longer be changed apart. A configuration that still sets one of the removed names, on a subclass or an instance, fails validation with `removed_setting`, so `FileValidator` raises `FileSecurityConfigurationError` instead of silently applying the default allow-list. Migrate a narrowed allow-list such as
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
config.ALLOWED_IMAGE_MIMES = frozenset({"image/png"})
|
|
36
|
+
config.ALLOWED_IMAGE_EXTENSIONS = frozenset({".png"})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
to
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
config.ALLOWED_IMAGE_TYPES = {"image/png": frozenset({".png"})}
|
|
43
|
+
```
|
|
44
|
+
|
|
7
45
|
## [3.0.0] - 2026-11-05
|
|
8
46
|
|
|
9
47
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: safeuploads
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: A comprehensive file security system for validating uploads and preventing attacks
|
|
5
5
|
Author-email: João Vitória Silva <joao@endurain.com>
|
|
6
6
|
License: MIT
|
|
@@ -66,14 +66,17 @@ validator = FileValidator()
|
|
|
66
66
|
@app.post("/upload")
|
|
67
67
|
async def upload_image(file: UploadFile):
|
|
68
68
|
try:
|
|
69
|
-
await validator.validate_image_file(file)
|
|
69
|
+
result = await validator.validate_image_file(file)
|
|
70
70
|
except FileValidationError as e:
|
|
71
71
|
raise HTTPException(status_code=400, detail=str(e))
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
|
|
73
|
+
# Store under a generated name; serve with the detected type
|
|
74
|
+
return {"filename": result.filename, "content_type": result.mime_type}
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
Every validation method returns a `ValidationResult` with the sanitized `filename` and the `mime_type` detected from the content. A file whose extension is not one allowed for its detected type, such as a WebP named `photo.jpg`, is rejected with `MimeTypeError` (`EXTENSION_MIME_MISMATCH`).
|
|
78
|
+
|
|
79
|
+
For synchronous code or worker threads, use the `*_sync` methods. They block the calling thread and return the same result:
|
|
77
80
|
|
|
78
81
|
```python
|
|
79
82
|
from safeuploads import FileValidator
|
|
@@ -81,7 +84,7 @@ from safeuploads import FileValidator
|
|
|
81
84
|
validator = FileValidator()
|
|
82
85
|
|
|
83
86
|
with open("activity.gpx", "rb") as fh:
|
|
84
|
-
|
|
87
|
+
result = validator.validate_activity_file_sync(fh, "activity.gpx")
|
|
85
88
|
```
|
|
86
89
|
|
|
87
90
|
Calling a `*_sync` method on a thread running an asyncio event loop raises `RuntimeError`; await the async method there instead.
|
|
@@ -97,8 +100,16 @@ validator = FileValidator()
|
|
|
97
100
|
# Or customize limits
|
|
98
101
|
config = FileSecurityConfig()
|
|
99
102
|
config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB
|
|
103
|
+
config.limits.max_image_pixels = 40_000_000 # 40 megapixels
|
|
100
104
|
config.limits.max_compression_ratio = 50
|
|
101
105
|
|
|
106
|
+
# Allowed content: each MIME type maps to the extensions its
|
|
107
|
+
# content may carry
|
|
108
|
+
config.ALLOWED_IMAGE_TYPES = {
|
|
109
|
+
"image/jpeg": frozenset({".jpg", ".jpeg"}),
|
|
110
|
+
"image/png": frozenset({".png"}),
|
|
111
|
+
}
|
|
112
|
+
|
|
102
113
|
# Opt in to strict ZIP checking: decompress every entry to
|
|
103
114
|
# reject archives with forged central-directory metadata
|
|
104
115
|
config.limits.verify_zip_decompression = True
|
|
@@ -144,9 +155,10 @@ except FileValidationError as err:
|
|
|
144
155
|
- **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection
|
|
145
156
|
- **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification
|
|
146
157
|
- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits
|
|
147
|
-
- **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip
|
|
158
|
+
- **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip, with the extension required to match the detected type
|
|
148
159
|
- **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files
|
|
149
160
|
- **Resource Monitoring**: wall-clock time and memory limits enforced via `ResourceMonitor` during the work
|
|
161
|
+
- **Image Dimension Limits**: Header-only pixel count check for PNG, JPEG and WebP that rejects decompression (pixel) bombs before the host decodes them
|
|
150
162
|
- **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing and FIT structure checks
|
|
151
163
|
- **Gzip Support**: Gzip archive validation with decompression bomb detection
|
|
152
164
|
- **Content Analysis**: Optional malware signature, web shell, and polyglot file detection
|
|
@@ -159,6 +171,7 @@ except FileValidationError as err:
|
|
|
159
171
|
|
|
160
172
|
- No built-in rate limiting (application-level concern — see documentation)
|
|
161
173
|
- MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis`
|
|
174
|
+
- Images are not decoded: only their dimension headers are checked, so decode and re-encode uploads in an isolated process (see the integration checklist)
|
|
162
175
|
- `SpooledTemporaryFile` uses the system default temp directory
|
|
163
176
|
|
|
164
177
|
## Documentation
|
|
@@ -52,14 +52,17 @@ validator = FileValidator()
|
|
|
52
52
|
@app.post("/upload")
|
|
53
53
|
async def upload_image(file: UploadFile):
|
|
54
54
|
try:
|
|
55
|
-
await validator.validate_image_file(file)
|
|
55
|
+
result = await validator.validate_image_file(file)
|
|
56
56
|
except FileValidationError as e:
|
|
57
57
|
raise HTTPException(status_code=400, detail=str(e))
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
|
|
59
|
+
# Store under a generated name; serve with the detected type
|
|
60
|
+
return {"filename": result.filename, "content_type": result.mime_type}
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
Every validation method returns a `ValidationResult` with the sanitized `filename` and the `mime_type` detected from the content. A file whose extension is not one allowed for its detected type, such as a WebP named `photo.jpg`, is rejected with `MimeTypeError` (`EXTENSION_MIME_MISMATCH`).
|
|
64
|
+
|
|
65
|
+
For synchronous code or worker threads, use the `*_sync` methods. They block the calling thread and return the same result:
|
|
63
66
|
|
|
64
67
|
```python
|
|
65
68
|
from safeuploads import FileValidator
|
|
@@ -67,7 +70,7 @@ from safeuploads import FileValidator
|
|
|
67
70
|
validator = FileValidator()
|
|
68
71
|
|
|
69
72
|
with open("activity.gpx", "rb") as fh:
|
|
70
|
-
|
|
73
|
+
result = validator.validate_activity_file_sync(fh, "activity.gpx")
|
|
71
74
|
```
|
|
72
75
|
|
|
73
76
|
Calling a `*_sync` method on a thread running an asyncio event loop raises `RuntimeError`; await the async method there instead.
|
|
@@ -83,8 +86,16 @@ validator = FileValidator()
|
|
|
83
86
|
# Or customize limits
|
|
84
87
|
config = FileSecurityConfig()
|
|
85
88
|
config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB
|
|
89
|
+
config.limits.max_image_pixels = 40_000_000 # 40 megapixels
|
|
86
90
|
config.limits.max_compression_ratio = 50
|
|
87
91
|
|
|
92
|
+
# Allowed content: each MIME type maps to the extensions its
|
|
93
|
+
# content may carry
|
|
94
|
+
config.ALLOWED_IMAGE_TYPES = {
|
|
95
|
+
"image/jpeg": frozenset({".jpg", ".jpeg"}),
|
|
96
|
+
"image/png": frozenset({".png"}),
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
# Opt in to strict ZIP checking: decompress every entry to
|
|
89
100
|
# reject archives with forged central-directory metadata
|
|
90
101
|
config.limits.verify_zip_decompression = True
|
|
@@ -130,9 +141,10 @@ except FileValidationError as err:
|
|
|
130
141
|
- **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection
|
|
131
142
|
- **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification
|
|
132
143
|
- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits
|
|
133
|
-
- **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip
|
|
144
|
+
- **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip, with the extension required to match the detected type
|
|
134
145
|
- **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files
|
|
135
146
|
- **Resource Monitoring**: wall-clock time and memory limits enforced via `ResourceMonitor` during the work
|
|
147
|
+
- **Image Dimension Limits**: Header-only pixel count check for PNG, JPEG and WebP that rejects decompression (pixel) bombs before the host decodes them
|
|
136
148
|
- **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing and FIT structure checks
|
|
137
149
|
- **Gzip Support**: Gzip archive validation with decompression bomb detection
|
|
138
150
|
- **Content Analysis**: Optional malware signature, web shell, and polyglot file detection
|
|
@@ -145,6 +157,7 @@ except FileValidationError as err:
|
|
|
145
157
|
|
|
146
158
|
- No built-in rate limiting (application-level concern — see documentation)
|
|
147
159
|
- MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis`
|
|
160
|
+
- Images are not decoded: only their dimension headers are checked, so decode and re-encode uploads in an isolated process (see the integration checklist)
|
|
148
161
|
- `SpooledTemporaryFile` uses the system default temp directory
|
|
149
162
|
|
|
150
163
|
## Documentation
|
|
@@ -54,14 +54,17 @@ validator = FileValidator()
|
|
|
54
54
|
@app.post("/upload")
|
|
55
55
|
async def upload_image(file: UploadFile):
|
|
56
56
|
try:
|
|
57
|
-
await validator.validate_image_file(file)
|
|
57
|
+
result = await validator.validate_image_file(file)
|
|
58
58
|
except FileValidationError as e:
|
|
59
59
|
raise HTTPException(status_code=400, detail=str(e))
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
|
|
61
|
+
# Store under a generated name; serve with the detected type
|
|
62
|
+
return {"filename": result.filename, "content_type": result.mime_type}
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
Every validation method returns a `ValidationResult` with the sanitized `filename` and the `mime_type` detected from the content. A file whose extension is not one allowed for its detected type, such as a WebP named `photo.jpg`, is rejected with `MimeTypeError` (`EXTENSION_MIME_MISMATCH`).
|
|
66
|
+
|
|
67
|
+
For synchronous code or worker threads, use the `*_sync` methods. They block the calling thread and return the same result:
|
|
65
68
|
|
|
66
69
|
```python
|
|
67
70
|
from safeuploads import FileValidator
|
|
@@ -69,7 +72,7 @@ from safeuploads import FileValidator
|
|
|
69
72
|
validator = FileValidator()
|
|
70
73
|
|
|
71
74
|
with open("activity.gpx", "rb") as fh:
|
|
72
|
-
|
|
75
|
+
result = validator.validate_activity_file_sync(fh, "activity.gpx")
|
|
73
76
|
```
|
|
74
77
|
|
|
75
78
|
Calling a `*_sync` method on a thread running an asyncio event loop raises `RuntimeError`; await the async method there instead.
|
|
@@ -85,8 +88,16 @@ validator = FileValidator()
|
|
|
85
88
|
# Or customize limits
|
|
86
89
|
config = FileSecurityConfig()
|
|
87
90
|
config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB
|
|
91
|
+
config.limits.max_image_pixels = 40_000_000 # 40 megapixels
|
|
88
92
|
config.limits.max_compression_ratio = 50
|
|
89
93
|
|
|
94
|
+
# Allowed content: each MIME type maps to the extensions its
|
|
95
|
+
# content may carry
|
|
96
|
+
config.ALLOWED_IMAGE_TYPES = {
|
|
97
|
+
"image/jpeg": frozenset({".jpg", ".jpeg"}),
|
|
98
|
+
"image/png": frozenset({".png"}),
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
# Opt in to strict ZIP checking: decompress every entry to
|
|
91
102
|
# reject archives with forged central-directory metadata
|
|
92
103
|
config.limits.verify_zip_decompression = True
|
|
@@ -132,9 +143,10 @@ except FileValidationError as err:
|
|
|
132
143
|
- **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection
|
|
133
144
|
- **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification
|
|
134
145
|
- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits
|
|
135
|
-
- **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip
|
|
146
|
+
- **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip, with the extension required to match the detected type
|
|
136
147
|
- **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files
|
|
137
148
|
- **Resource Monitoring**: wall-clock time and memory limits enforced via `ResourceMonitor` during the work
|
|
149
|
+
- **Image Dimension Limits**: Header-only pixel count check for PNG, JPEG and WebP that rejects decompression (pixel) bombs before the host decodes them
|
|
138
150
|
- **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing and FIT structure checks
|
|
139
151
|
- **Gzip Support**: Gzip archive validation with decompression bomb detection
|
|
140
152
|
- **Content Analysis**: Optional malware signature, web shell, and polyglot file detection
|
|
@@ -147,6 +159,7 @@ except FileValidationError as err:
|
|
|
147
159
|
|
|
148
160
|
- No built-in rate limiting (application-level concern — see [Rate Limiting](rate-limiting.md) guide)
|
|
149
161
|
- MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis`
|
|
162
|
+
- Images are not decoded: only their dimension headers are checked, so decode and re-encode uploads in an isolated process (see the integration checklist)
|
|
150
163
|
- `SpooledTemporaryFile` uses the system default temp directory
|
|
151
164
|
|
|
152
165
|
## Documentation
|
|
@@ -25,6 +25,7 @@ safeuploads/
|
|
|
25
25
|
│ ├── mime_validator.py # Content MIME detection and enforcement
|
|
26
26
|
│ ├── windows_validator.py # Windows reserved name checks
|
|
27
27
|
│ ├── fit_validator.py # FIT container structure checks
|
|
28
|
+
│ ├── image_dimension_validator.py # Pixel count from image headers
|
|
28
29
|
│ ├── compression_validator.py # ZIP bomb detection
|
|
29
30
|
│ └── xml_validator.py # XXE-safe XML parsing
|
|
30
31
|
└── inspectors/
|
|
@@ -39,18 +40,19 @@ safeuploads/
|
|
|
39
40
|
| Component | Responsibility |
|
|
40
41
|
|---|---|
|
|
41
42
|
| `FileValidator` | Orchestrates validation for each file type. Manages streaming, resource monitoring and audit events, and delegates to validators/inspectors. The async methods offload blocking inspection to a worker thread (or a configured `executor`); the `*_sync` methods run the same pipeline in the calling thread and refuse to run on an event-loop thread. Exposes them as `filename_validator`, `signature_validator`, `mime_validator` and the other validator attributes; `magic_mime` and `magic_available` are properties that delegate to `mime_validator`. |
|
|
42
|
-
| `FileSecurityConfig` | Centralizes all configuration: allowed MIME types
|
|
43
|
+
| `FileSecurityConfig` | Centralizes all configuration: allowed MIME types mapped to the extensions each permits, blocked extensions, Unicode characters, and Windows reserved names. |
|
|
43
44
|
| `SecurityLimits` | Holds numeric thresholds: file sizes, compression ratios, timeouts, entry limits, resource caps. |
|
|
44
45
|
| `BaseValidator` | Abstract base class; validators inherit and implement `validate()`. |
|
|
45
46
|
| `UnicodeSecurityValidator` | NFC normalization and dangerous character detection (rejects with `UnicodeSecurityError`). |
|
|
46
47
|
| `ExtensionSecurityValidator` | Blocked, dangerous and compound extension checks. `validate_allowed` checks an extension against the allowlist and the blocked list. |
|
|
47
48
|
| `WindowsSecurityValidator` | Rejects Windows reserved device names. |
|
|
48
49
|
| `FilenameSecurityValidator` | Strips path components and dangerous characters from a filename and limits its length to `max_sanitized_name_length`. |
|
|
49
|
-
| `FileSignatureValidator` | Checks that the file header matches the signatures of the expected type (image, ZIP, gzip, activity or FIT). |
|
|
50
|
-
| `MimeTypeValidator` | Detects the MIME type from content with libmagic (serialized by a lock, never falling back to the filename)
|
|
50
|
+
| `FileSignatureValidator` | Checks that the file header matches the signatures of the expected type (image: JPEG, PNG or WebP; ZIP, gzip, activity or FIT). |
|
|
51
|
+
| `MimeTypeValidator` | Detects the MIME type from content with libmagic (serialized by a lock, never falling back to the filename), enforces the allowed MIME types and requires the filename's extension to be one listed for the detected type. |
|
|
51
52
|
| `CompressionSecurityValidator` | Checks compression ratio, uncompressed size, entry count, and nested archive limits, and that the listed entries fill the file exactly. |
|
|
52
53
|
| `XmlSecurityValidator` | Parses XML with `defusedxml`, blocking DTDs and external entities (raises `XmlSecurityError`). |
|
|
53
54
|
| `FitSecurityValidator` | Checks the FIT container: headers, header CRC, data size, CRC-16 per section, and that the sections cover the file exactly. |
|
|
55
|
+
| `ImageDimensionValidator` | Reads width and height from the PNG IHDR chunk, the first JPEG frame header (SOF) or the first WebP chunk (VP8, VP8L or VP8X canvas) without decoding pixels, and rejects an image whose pixel count exceeds `max_image_pixels` (raises `ImageDimensionError`). A missing or malformed dimension header raises `FileSignatureError` with `IMAGE_INVALID_STRUCTURE`. |
|
|
54
56
|
| `ZipContentInspector` | Iterates ZIP entries checking for traversal, executables, scripts, symlinks, and recursive structures, and runs the top-level Unicode, Windows reserved-name and blocked-extension checks on each entry name. With `allow_nested_archives`, inspects every inner ZIP like the top level. |
|
|
55
57
|
| `GzipContentInspector` | Streams gzip decompression, checking ratio and size progressively. |
|
|
56
58
|
| `ContentSecurityInspector` | Scans raw bytes for structurally verified executable headers, web shells, and polyglot patterns (image and activity uploads). |
|
|
@@ -61,7 +63,7 @@ safeuploads/
|
|
|
61
63
|
|
|
62
64
|
## Validation Pipelines
|
|
63
65
|
|
|
64
|
-
The `validate_*_file_sync` methods run the identical steps listed below in the calling thread, without the worker-thread offload.
|
|
66
|
+
The `validate_*_file_sync` methods run the identical steps listed below in the calling thread, without the worker-thread offload. Every method returns a `ValidationResult` with the sanitized filename and the detected MIME type.
|
|
65
67
|
|
|
66
68
|
### Image Validation (`validate_image_file`)
|
|
67
69
|
|
|
@@ -91,14 +93,18 @@ UploadFile
|
|
|
91
93
|
│
|
|
92
94
|
▼
|
|
93
95
|
┌─────────────────────────────┐
|
|
94
|
-
│ 4.
|
|
95
|
-
│ (
|
|
96
|
+
│ 4. Stream to SpooledTempFile│
|
|
97
|
+
│ (size checked per chunk; │
|
|
98
|
+
│ memory < 10MB → disk) │
|
|
96
99
|
└─────────────┬───────────────┘
|
|
97
100
|
│
|
|
98
101
|
▼
|
|
99
102
|
┌─────────────────────────────┐
|
|
100
|
-
│ 5. MIME Type
|
|
103
|
+
│ 5. MIME Type Check │
|
|
101
104
|
│ (python-magic, first 8KB)│
|
|
105
|
+
│ ├── Type allowed │
|
|
106
|
+
│ └── Extension listed for │
|
|
107
|
+
│ the detected type │
|
|
102
108
|
└─────────────┬───────────────┘
|
|
103
109
|
│
|
|
104
110
|
▼
|
|
@@ -109,7 +115,18 @@ UploadFile
|
|
|
109
115
|
│
|
|
110
116
|
▼
|
|
111
117
|
┌─────────────────────────────┐
|
|
112
|
-
│ 7.
|
|
118
|
+
│ 7. Dimension Check │
|
|
119
|
+
│ (header only, no decode) │
|
|
120
|
+
│ ├── PNG IHDR │
|
|
121
|
+
│ ├── JPEG frame header │
|
|
122
|
+
│ ├── WebP VP8/VP8L/VP8X │
|
|
123
|
+
│ └── width × height ≤ │
|
|
124
|
+
│ max_image_pixels │
|
|
125
|
+
└─────────────┬───────────────┘
|
|
126
|
+
│
|
|
127
|
+
▼
|
|
128
|
+
┌─────────────────────────────┐
|
|
129
|
+
│ 8. Content Analysis │
|
|
113
130
|
│ (if enabled) │
|
|
114
131
|
│ ├── Executable signatures│
|
|
115
132
|
│ ├── Script injection │
|
|
@@ -118,13 +135,13 @@ UploadFile
|
|
|
118
135
|
│
|
|
119
136
|
▼
|
|
120
137
|
┌─────────────────────────────┐
|
|
121
|
-
│
|
|
138
|
+
│ 9. ResourceMonitor END │
|
|
122
139
|
└─────────────┬───────────────┘
|
|
123
140
|
│
|
|
124
141
|
▼
|
|
125
|
-
|
|
126
|
-
│
|
|
127
|
-
|
|
142
|
+
┌──────────────────────────────┐
|
|
143
|
+
│ 10. Audit: VALIDATION_SUCCESS│
|
|
144
|
+
└──────────────────────────────┘
|
|
128
145
|
```
|
|
129
146
|
|
|
130
147
|
### ZIP Validation (`validate_zip_file`)
|
|
@@ -162,6 +179,8 @@ UploadFile
|
|
|
162
179
|
▼
|
|
163
180
|
┌─────────────────────────────┐
|
|
164
181
|
│ 6. MIME + Signature Check │
|
|
182
|
+
│ (extension listed for │
|
|
183
|
+
│ the detected type) │
|
|
165
184
|
└─────────────┬───────────────┘
|
|
166
185
|
│
|
|
167
186
|
▼
|
|
@@ -245,8 +264,10 @@ UploadFile (.gpx, .tcx, .fit)
|
|
|
245
264
|
│ 5. MIME + Signature Check │
|
|
246
265
|
│ ├── FIT: binary signature │
|
|
247
266
|
│ │ (.FIT at bytes 8-11) │
|
|
248
|
-
│
|
|
249
|
-
│
|
|
267
|
+
│ ├── GPX/TCX: XML header │
|
|
268
|
+
│ │ (<?xml) │
|
|
269
|
+
│ └── Extension listed for │
|
|
270
|
+
│ the detected type │
|
|
250
271
|
└──────────────┬───────────────┘
|
|
251
272
|
│
|
|
252
273
|
▼
|
|
@@ -310,6 +331,8 @@ UploadFile (.gz)
|
|
|
310
331
|
┌──────────────────────────────┐
|
|
311
332
|
│ 5. MIME + Signature Check │
|
|
312
333
|
│ (gzip magic: \x1f\x8b) │
|
|
334
|
+
│ (extension listed for │
|
|
335
|
+
│ the detected type) │
|
|
313
336
|
└──────────────┬───────────────┘
|
|
314
337
|
│
|
|
315
338
|
▼
|
|
@@ -337,8 +360,7 @@ UploadFile (.gz)
|
|
|
337
360
|
|
|
338
361
|
| Stage | What is read | Buffer size |
|
|
339
362
|
|---|---|---|
|
|
340
|
-
|
|
|
341
|
-
| Stream to temp | Chunked reads to `SpooledTemporaryFile` | In-memory up to `max_memory_buffer_size` (10 MB), then disk |
|
|
363
|
+
| Stream to temp (size check) | Chunked reads (`chunk_size`, 64 KB default) to `SpooledTemporaryFile`, counted against the type's size limit | In-memory up to `max_memory_buffer_size` (10 MB), then disk |
|
|
342
364
|
| MIME detection | First 8 KB via `python-magic` | 8 KB |
|
|
343
365
|
| Signature check | First 4-12 bytes depending on format | Minimal |
|
|
344
366
|
| Compression validation | ZIP central directory, local headers and data descriptors | Metadata; entries with a data descriptor are inflated in `chunk_size` chunks |
|
|
@@ -347,6 +369,7 @@ UploadFile (.gz)
|
|
|
347
369
|
| Gzip inspection | Chunked streaming decompression | `chunk_size` at a time |
|
|
348
370
|
| XML validation | Chunked feed to `defusedxml`'s parser, no element tree built | `chunk_size` at a time |
|
|
349
371
|
| FIT validation | Chunked CRC-16 over each section | `chunk_size` at a time |
|
|
372
|
+
| Image dimension check | PNG and WebP: first 30 bytes. JPEG: the marker and length of each segment up to the frame header, skipping segment payloads by seek | Minimal; a run of 0xFF fill bytes is read `chunk_size` at a time |
|
|
350
373
|
| Content analysis | Byte scan of image and activity uploads (not ZIP) | Bounded by `content_scan_max_size` (50 MB); about twice the sample while matching |
|
|
351
374
|
|
|
352
375
|
---
|
|
@@ -365,6 +388,7 @@ Exception
|
|
|
365
388
|
│ ├── FileSizeError
|
|
366
389
|
│ ├── MimeTypeError
|
|
367
390
|
│ ├── FileSignatureError
|
|
391
|
+
│ ├── ImageDimensionError
|
|
368
392
|
│ ├── CompressionSecurityError
|
|
369
393
|
│ │ ├── ZipBombError
|
|
370
394
|
│ │ └── ZipContentError
|
|
@@ -388,8 +412,9 @@ All `FileSecurityError` subclasses carry an `error_code` from
|
|
|
388
412
|
| `ExtensionSecurityError` | `EXTENSION_BLOCKED` |
|
|
389
413
|
| `WindowsReservedNameError` | `WINDOWS_RESERVED_NAME` |
|
|
390
414
|
| `FileSizeError` | `FILE_TOO_LARGE` |
|
|
391
|
-
| `MimeTypeError` | `MIME_TYPE_INVALID` (uploads whose detected MIME is not allowed are reported as `MIME_TYPE_MISMATCH`) |
|
|
392
|
-
| `FileSignatureError` | `FILE_SIGNATURE_MISMATCH` |
|
|
415
|
+
| `MimeTypeError` | `MIME_TYPE_INVALID` (uploads whose detected MIME is not allowed are reported as `MIME_TYPE_MISMATCH`, and an extension not listed for the detected MIME as `EXTENSION_MIME_MISMATCH`) |
|
|
416
|
+
| `FileSignatureError` | `FILE_SIGNATURE_MISMATCH` (a missing or invalid FIT structure is reported as `FIT_INVALID_STRUCTURE`, and a missing or invalid image dimension header as `IMAGE_INVALID_STRUCTURE`) |
|
|
417
|
+
| `ImageDimensionError` | `IMAGE_DIMENSIONS_EXCEEDED` |
|
|
393
418
|
| `CompressionSecurityError` | `ZIP_INVALID_STRUCTURE` |
|
|
394
419
|
| `ZipBombError` | `ZIP_BOMB_DETECTED` |
|
|
395
420
|
| `ZipContentError` | `ZIP_CONTENT_THREAT` (the ZIP inspector passes the code of the first threat found) |
|
|
@@ -30,6 +30,12 @@ that addresses it.
|
|
|
30
30
|
`FileSecurityConfig` (not relying solely on defaults).
|
|
31
31
|
- [ ] `SecurityLimits` tuned for your use case:
|
|
32
32
|
- `max_image_size` — appropriate for expected upload sizes.
|
|
33
|
+
- `max_image_pixels` — default 89,478,485 (Pillow's
|
|
34
|
+
`Image.MAX_IMAGE_PIXELS`); lower it to the largest image
|
|
35
|
+
your application needs. An image whose header declares
|
|
36
|
+
more pixels is rejected with `IMAGE_DIMENSIONS_EXCEEDED`,
|
|
37
|
+
and one whose dimension header is missing or malformed
|
|
38
|
+
with `IMAGE_INVALID_STRUCTURE`.
|
|
33
39
|
- `max_zip_size` — set to the maximum acceptable ZIP size.
|
|
34
40
|
- `max_activity_file_size` — set if accepting GPX/TCX/FIT.
|
|
35
41
|
FIT uploads must be structurally valid (headers, CRCs,
|
|
@@ -47,12 +53,15 @@ that addresses it.
|
|
|
47
53
|
raises `FileSecurityConfigurationError` otherwise), and every
|
|
48
54
|
inner ZIP is inspected like the top level; one that is
|
|
49
55
|
oversized, corrupt or uninspectable is rejected.
|
|
50
|
-
- [ ]
|
|
51
|
-
|
|
56
|
+
- [ ] `ALLOWED_*_TYPES` reviewed and narrowed to the MIME types
|
|
57
|
+
your application accepts and the extensions each may carry.
|
|
52
58
|
- [ ] `FileSecurityConfigurationError` from the `FileValidator`
|
|
53
59
|
constructor handled at startup: it is raised for any
|
|
54
|
-
error-severity configuration issue (
|
|
55
|
-
|
|
60
|
+
error-severity configuration issue (a removed
|
|
61
|
+
`ALLOWED_*_MIMES` or `ALLOWED_*_EXTENSIONS` setting, invalid
|
|
62
|
+
limit types, non-positive limits, an empty allowed-types
|
|
63
|
+
mapping, a MIME
|
|
64
|
+
type with no extensions, empty or uppercase extension entries,
|
|
56
65
|
empty block lists or reserved names, reserved names that are
|
|
57
66
|
not lowercase, a content scan window smaller than an image or
|
|
58
67
|
activity size limit), including sets overridden on the
|
|
@@ -71,6 +80,25 @@ that addresses it.
|
|
|
71
80
|
- [ ] Consider supplemental antivirus scanning (ClamAV or
|
|
72
81
|
similar) for high-risk environments.
|
|
73
82
|
|
|
83
|
+
## Image Processing
|
|
84
|
+
|
|
85
|
+
safeuploads checks the dimensions an image header declares but
|
|
86
|
+
does not decode pixel data, so decoder bugs and corrupt image
|
|
87
|
+
bodies are not caught (see
|
|
88
|
+
[Image Decompression Bombs](threat-model.md#image-decompression-bombs-cwe-400)).
|
|
89
|
+
|
|
90
|
+
- [ ] Images are decoded, resized or re-encoded in an isolated
|
|
91
|
+
worker process or container with its own memory and time
|
|
92
|
+
limits, not in the process that serves requests.
|
|
93
|
+
- [ ] The decoder's own pixel limit stays set at or below
|
|
94
|
+
`max_image_pixels` (with Pillow, keep `Image.MAX_IMAGE_PIXELS`;
|
|
95
|
+
never set it to `None`).
|
|
96
|
+
- [ ] A re-encoded copy is stored or served instead of the
|
|
97
|
+
uploaded bytes, so metadata (EXIF, GPS), data appended to the
|
|
98
|
+
image and secondary images are dropped.
|
|
99
|
+
- [ ] Image decoder libraries (Pillow, libjpeg-turbo, libpng,
|
|
100
|
+
libwebp) kept up to date.
|
|
101
|
+
|
|
74
102
|
## Audit Logging
|
|
75
103
|
|
|
76
104
|
- [ ] `enable_audit_logging` set to `True` in production.
|
|
@@ -88,8 +116,9 @@ that addresses it.
|
|
|
88
116
|
- [ ] Application catches specific exception types
|
|
89
117
|
(`FileSizeError`, `ExtensionSecurityError`, etc.) and
|
|
90
118
|
returns user-appropriate messages. Malformed or forbidden XML
|
|
91
|
-
(`XmlSecurityError`)
|
|
92
|
-
(`
|
|
119
|
+
(`XmlSecurityError`), content threats (`ContentThreatError`)
|
|
120
|
+
and images over the pixel limit (`ImageDimensionError`) are
|
|
121
|
+
`FileValidationError` subclasses.
|
|
93
122
|
- [ ] `FileProcessingError` (including `ResourceLimitError`) is
|
|
94
123
|
caught as well: it is not a `FileValidationError`, and it is
|
|
95
124
|
raised only for internal errors and exceeded resource limits.
|
|
@@ -108,13 +137,20 @@ that addresses it.
|
|
|
108
137
|
- [ ] Uploaded files stored outside the web-accessible
|
|
109
138
|
document root.
|
|
110
139
|
- [ ] Uploaded files renamed to random identifiers (UUIDs);
|
|
111
|
-
original filenames stored in metadata only.
|
|
140
|
+
original filenames stored in metadata only. If the stored name
|
|
141
|
+
needs an extension, take it from `ValidationResult.filename`,
|
|
142
|
+
not from the uploaded name: it is sanitized, and its extension
|
|
143
|
+
is one the configuration lists for the detected MIME type.
|
|
144
|
+
`mime_type` alone cannot choose it, since GPX and TCX share
|
|
145
|
+
their XML types. Content is not checked as GPX rather than TCX,
|
|
146
|
+
so an importer must still handle XML that does not match its
|
|
147
|
+
extension.
|
|
112
148
|
- [ ] Storage directory permissions restrict access to the
|
|
113
149
|
application user only (`0700` or equivalent).
|
|
114
150
|
- [ ] If serving uploaded files back to users, use:
|
|
115
151
|
- `Content-Disposition: attachment` to force download.
|
|
116
|
-
- `Content-Type` set from
|
|
117
|
-
the filename.
|
|
152
|
+
- `Content-Type` set from `ValidationResult.mime_type`,
|
|
153
|
+
not from the filename.
|
|
118
154
|
- `X-Content-Type-Options: nosniff` header.
|
|
119
155
|
|
|
120
156
|
## Security Headers (for serving uploaded content)
|
|
@@ -137,7 +173,7 @@ that addresses it.
|
|
|
137
173
|
only the time limit applies.
|
|
138
174
|
- [ ] Both limits are checked at loop boundaries (upload
|
|
139
175
|
streaming, ZIP entry loops, gzip decompression, each XML
|
|
140
|
-
chunk) and on exit; a single blocking call, such as one
|
|
176
|
+
chunk, each JPEG segment before the frame header) and on exit; a single blocking call, such as one
|
|
141
177
|
`zipfile` central-directory parse or one libmagic call, is
|
|
142
178
|
not interrupted mid-call. A `ResourceLimitError` keeps its
|
|
143
179
|
own `RESOURCE_TIME_EXCEEDED` or `RESOURCE_MEMORY_EXCEEDED`
|
|
@@ -397,8 +397,17 @@ extension.
|
|
|
397
397
|
validated.
|
|
398
398
|
- The detected MIME type is validated against the allowlist for
|
|
399
399
|
the file type being validated.
|
|
400
|
+
- The filename's extension must be one the configuration lists
|
|
401
|
+
for the detected MIME type (`ALLOWED_*_TYPES`), so a file that
|
|
402
|
+
is a valid, allowed image under another image format's name
|
|
403
|
+
(for example a WebP named `photo.jpg`) is rejected with
|
|
404
|
+
`EXTENSION_MIME_MISMATCH`. Downstream tools that pick a decoder
|
|
405
|
+
or a `Content-Type` from the extension never see a name that
|
|
406
|
+
contradicts the content.
|
|
400
407
|
- File signatures (magic bytes) are verified independently of
|
|
401
|
-
the MIME type.
|
|
408
|
+
the MIME type. A WebP must carry `WEBP` at bytes 8-11 of its
|
|
409
|
+
RIFF container, so WAV, AVI and other RIFF files are not
|
|
410
|
+
accepted as images.
|
|
402
411
|
|
|
403
412
|
### Polyglot Files
|
|
404
413
|
|
|
@@ -517,7 +526,8 @@ significantly during validation consumes all available memory.
|
|
|
517
526
|
same process can also trip the limit; on Windows memory
|
|
518
527
|
accounting is disabled.
|
|
519
528
|
- The limit is checked at loop boundaries (upload streaming, ZIP
|
|
520
|
-
entry loops, gzip decompression, each XML chunk
|
|
529
|
+
entry loops, gzip decompression, each XML chunk, each JPEG
|
|
530
|
+
segment before the frame header) and on exit,
|
|
521
531
|
and raises `ResourceLimitError` with
|
|
522
532
|
`RESOURCE_MEMORY_EXCEEDED`. A single blocking call is not
|
|
523
533
|
interrupted mid-call.
|
|
@@ -576,6 +586,73 @@ size, similar to ZIP bombs.
|
|
|
576
586
|
- Deflate data that `zlib` cannot decode is rejected with
|
|
577
587
|
`CompressionSecurityError` (`ZIP_CORRUPT`).
|
|
578
588
|
|
|
589
|
+
### Image Decompression Bombs (CWE-400)
|
|
590
|
+
|
|
591
|
+
**Attack:** A small image whose header declares a huge canvas,
|
|
592
|
+
such as a 50 KB PNG declaring 60,000 × 60,000 pixels or a WebP
|
|
593
|
+
canvas of 16,777,216 × 16,777,216, passes every byte-level
|
|
594
|
+
check and exhausts memory when the host decodes it, for example
|
|
595
|
+
to make a thumbnail. A 24-bit decode needs 3 bytes per pixel,
|
|
596
|
+
so 60,000 × 60,000 pixels is about 10 GB.
|
|
597
|
+
|
|
598
|
+
**Mitigations:**
|
|
599
|
+
|
|
600
|
+
- `ImageDimensionValidator` reads width and height from the
|
|
601
|
+
image header without decoding pixel data, and rejects an
|
|
602
|
+
image whose width × height exceeds `max_image_pixels` with
|
|
603
|
+
`ImageDimensionError` (`IMAGE_DIMENSIONS_EXCEEDED`). The
|
|
604
|
+
default, 89,478,485, is Pillow's `Image.MAX_IMAGE_PIXELS`,
|
|
605
|
+
about 256 MB decoded at 24 bits per pixel. Configuration
|
|
606
|
+
validation rejects a limit of 0 or less and warns above twice
|
|
607
|
+
the default, where Pillow refuses to open an image.
|
|
608
|
+
- PNG: the first chunk must be IHDR with 13 bytes of data, and
|
|
609
|
+
width and height must be 1 to 2³¹ − 1.
|
|
610
|
+
- JPEG: the marker segments are walked from the start of image
|
|
611
|
+
to the first frame header (SOF0 to SOF15, except DHT, JPG and
|
|
612
|
+
DAC). Each segment's payload is skipped by its length, so a
|
|
613
|
+
frame header behind large EXIF, XMP or ICC segments is still
|
|
614
|
+
found. Scan data or end of image before a frame header, a
|
|
615
|
+
truncated segment, a segment length below 2, a standalone
|
|
616
|
+
marker before the frame, and a zero width or height
|
|
617
|
+
(including a height deferred to a DNL segment) are rejected
|
|
618
|
+
with `FileSignatureError` (`IMAGE_INVALID_STRUCTURE`).
|
|
619
|
+
- WebP: the first chunk after the `WEBP` form type must be
|
|
620
|
+
`VP8 ` (a key frame with a valid start code; the two
|
|
621
|
+
upscaling bits of each dimension are ignored), `VP8L`
|
|
622
|
+
(signature 0x2F, version 0) or `VP8X`, whose canvas size is
|
|
623
|
+
checked. Any other first chunk is rejected with
|
|
624
|
+
`IMAGE_INVALID_STRUCTURE`.
|
|
625
|
+
- The check runs after the signature check and before content
|
|
626
|
+
analysis, on the upload streamed to a spooled temp file. The
|
|
627
|
+
JPEG walk checks the active `ResourceMonitor` on every
|
|
628
|
+
segment; its cost grows with the number of segments before
|
|
629
|
+
the frame header, so a 20 MB JPEG made only of 4-byte
|
|
630
|
+
segments takes seconds, bounded by `max_image_size` and
|
|
631
|
+
`max_validation_time_seconds`. A run of 0xFF fill bytes is
|
|
632
|
+
skipped `chunk_size` bytes at a time.
|
|
633
|
+
|
|
634
|
+
**Decoding trust boundary:**
|
|
635
|
+
|
|
636
|
+
- safeuploads does **not** decode images. Only the dimension
|
|
637
|
+
header is parsed: compressed pixel data, ancillary chunks and
|
|
638
|
+
metadata are not validated, so a truncated or corrupt image
|
|
639
|
+
body is accepted.
|
|
640
|
+
- Bugs in image decoders (for example CVE-2023-4863 in libwebp)
|
|
641
|
+
are triggered by decoding. The library cannot detect them, and
|
|
642
|
+
decoding inside the validator would expose the validating
|
|
643
|
+
process to them.
|
|
644
|
+
- Only the first image is checked. The APNG and WebP
|
|
645
|
+
specifications require animation frames to fit the declared
|
|
646
|
+
canvas, but the number of frames is not limited, and data
|
|
647
|
+
after a JPEG's end of image (such as the secondary images of
|
|
648
|
+
an MPO file) is not parsed.
|
|
649
|
+
- A host that decodes uploads should decode in an isolated
|
|
650
|
+
worker process with a memory limit, keep the decoder's own
|
|
651
|
+
pixel limit set (for example Pillow's `Image.MAX_IMAGE_PIXELS`)
|
|
652
|
+
and store a re-encoded copy instead of the original bytes;
|
|
653
|
+
re-encoding also drops metadata and data appended to the
|
|
654
|
+
image. See the integration checklist.
|
|
655
|
+
|
|
579
656
|
---
|
|
580
657
|
|
|
581
658
|
## Audit & Observability
|
|
@@ -130,9 +130,9 @@ validator = FileValidator(config=config)
|
|
|
130
130
|
```json
|
|
131
131
|
{
|
|
132
132
|
"error": "invalid_mime_type",
|
|
133
|
-
"message": "Invalid file type. Detected: application/x-dosexec. Allowed: image/jpeg, image/
|
|
133
|
+
"message": "Invalid file type. Detected: application/x-dosexec. Allowed: image/jpeg, image/png, image/webp",
|
|
134
134
|
"detected_mime": "application/x-dosexec",
|
|
135
|
-
"allowed_mimes": ["image/jpeg", "image/
|
|
135
|
+
"allowed_mimes": ["image/jpeg", "image/png", "image/webp"],
|
|
136
136
|
"error_code": "MIME_TYPE_MISMATCH"
|
|
137
137
|
}
|
|
138
138
|
```
|