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.
Files changed (108) hide show
  1. {safeuploads-3.0.0 → safeuploads-4.0.0}/CHANGELOG.md +38 -0
  2. {safeuploads-3.0.0 → safeuploads-4.0.0}/PKG-INFO +20 -7
  3. {safeuploads-3.0.0 → safeuploads-4.0.0}/README.md +19 -6
  4. {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/index.md +19 -6
  5. {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/security/architecture.md +43 -18
  6. {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/security/integration-checklist.md +46 -10
  7. {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/security/threat-model.md +79 -2
  8. {safeuploads-3.0.0 → safeuploads-4.0.0}/examples/README.md +2 -2
  9. {safeuploads-3.0.0 → safeuploads-4.0.0}/examples/fastapi_example.py +27 -19
  10. {safeuploads-3.0.0 → safeuploads-4.0.0}/pyproject.toml +1 -1
  11. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/__init__.py +6 -1
  12. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/config.py +206 -144
  13. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/exceptions.py +48 -4
  14. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/file_validator.py +198 -296
  15. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/utils.py +18 -2
  16. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/__init__.py +4 -2
  17. safeuploads-4.0.0/safeuploads/validators/image_dimension_validator.py +336 -0
  18. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/mime_validator.py +44 -15
  19. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/signature_validator.py +9 -0
  20. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/conftest.py +18 -29
  21. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/test_fuzz_images.py +52 -1
  22. safeuploads-4.0.0/tests/images.py +188 -0
  23. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/inspectors/test_content_inspector.py +5 -17
  24. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_config.py +138 -81
  25. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_config_validation.py +122 -22
  26. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_exceptions.py +45 -0
  27. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_file_validator.py +202 -157
  28. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_performance.py +4 -34
  29. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_sync_validation.py +171 -19
  30. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_validate_activity_file.py +3 -2
  31. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_extension_validator.py +10 -8
  32. safeuploads-4.0.0/tests/validators/test_image_dimension_validator.py +291 -0
  33. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_mime_validator.py +75 -7
  34. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_signature_validator.py +37 -1
  35. {safeuploads-3.0.0 → safeuploads-4.0.0}/uv.lock +1 -1
  36. {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/conventional-commits/SKILL.md +0 -0
  37. {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/conventional-commits/references/pull-requests.md +0 -0
  38. {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/debugging/SKILL.md +0 -0
  39. {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/safeuploads-library/SKILL.md +0 -0
  40. {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/safeuploads-library/references/code-standards.md +0 -0
  41. {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/safeuploads-library/references/security.md +0 -0
  42. {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/safeuploads-library/references/testing.md +0 -0
  43. {safeuploads-3.0.0 → safeuploads-4.0.0}/.agents/skills/skill-authoring/SKILL.md +0 -0
  44. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/CODEOWNERS +0 -0
  45. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/FUNDING.yml +0 -0
  46. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  47. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  48. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  49. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  50. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/copilot-instructions.md +0 -0
  51. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/dependabot.yml +0 -0
  52. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/scripts/check_conventional_commits.py +0 -0
  53. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/scripts/smoke_import.py +0 -0
  54. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/audit-safeuploads.yml +0 -0
  55. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/conventional-commits.yml +0 -0
  56. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/docs.yml +0 -0
  57. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/fuzz.yml +0 -0
  58. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/lint-and-test.yml +0 -0
  59. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/mirror.yml +0 -0
  60. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/publish-safeuploads.yml +0 -0
  61. {safeuploads-3.0.0 → safeuploads-4.0.0}/.github/workflows/test-matrix-safeuploads.yml +0 -0
  62. {safeuploads-3.0.0 → safeuploads-4.0.0}/.gitignore +0 -0
  63. {safeuploads-3.0.0 → safeuploads-4.0.0}/AGENTS.md +0 -0
  64. {safeuploads-3.0.0 → safeuploads-4.0.0}/CODE_OF_CONDUCT.md +0 -0
  65. {safeuploads-3.0.0 → safeuploads-4.0.0}/CONTRIBUTING.md +0 -0
  66. {safeuploads-3.0.0 → safeuploads-4.0.0}/LICENSE.md +0 -0
  67. {safeuploads-3.0.0 → safeuploads-4.0.0}/SECURITY.md +0 -0
  68. {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/api.md +0 -0
  69. {safeuploads-3.0.0 → safeuploads-4.0.0}/docs/rate-limiting.md +0 -0
  70. {safeuploads-3.0.0 → safeuploads-4.0.0}/mkdocs.yml +0 -0
  71. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/audit.py +0 -0
  72. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/enums.py +0 -0
  73. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/__init__.py +0 -0
  74. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/base.py +0 -0
  75. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/content_inspector.py +0 -0
  76. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/gzip_inspector.py +0 -0
  77. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/inspectors/zip_inspector.py +0 -0
  78. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/protocols.py +0 -0
  79. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/py.typed +0 -0
  80. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/base.py +0 -0
  81. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/compression_validator.py +0 -0
  82. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/extension_validator.py +0 -0
  83. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/filename_validator.py +0 -0
  84. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/fit_validator.py +0 -0
  85. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/unicode_validator.py +0 -0
  86. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/windows_validator.py +0 -0
  87. {safeuploads-3.0.0 → safeuploads-4.0.0}/safeuploads/validators/xml_validator.py +0 -0
  88. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/__init__.py +0 -0
  89. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/__init__.py +0 -0
  90. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/helpers.py +0 -0
  91. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/test_fuzz_config.py +0 -0
  92. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/test_fuzz_filenames.py +0 -0
  93. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/fuzz/test_fuzz_zip.py +0 -0
  94. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/inspectors/__init__.py +0 -0
  95. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/inspectors/test_gzip_inspector.py +0 -0
  96. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/inspectors/test_zip_inspector.py +0 -0
  97. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_audit.py +0 -0
  98. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_enums.py +0 -0
  99. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_fastapi_integration.py +0 -0
  100. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_utils.py +0 -0
  101. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/test_validate_gzip_file.py +0 -0
  102. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/__init__.py +0 -0
  103. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_compression_validator.py +0 -0
  104. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_filename_validator.py +0 -0
  105. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_fit_validator.py +0 -0
  106. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_unicode_validator.py +0 -0
  107. {safeuploads-3.0.0 → safeuploads-4.0.0}/tests/validators/test_windows_validator.py +0 -0
  108. {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.0.0
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
- return {"status": "success", "filename": file.filename}
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
- For synchronous code or worker threads, use the `*_sync` methods. They block the calling thread and return the sanitized filename:
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
- safe_name = validator.validate_activity_file_sync(fh, "activity.gpx")
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
- return {"status": "success", "filename": file.filename}
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
- For synchronous code or worker threads, use the `*_sync` methods. They block the calling thread and return the sanitized filename:
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
- safe_name = validator.validate_activity_file_sync(fh, "activity.gpx")
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
- return {"status": "success", "filename": file.filename}
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
- For synchronous code or worker threads, use the `*_sync` methods. They block the calling thread and return the sanitized filename:
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
- safe_name = validator.validate_activity_file_sync(fh, "activity.gpx")
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, extensions, blocked extensions, Unicode characters, and Windows reserved names. |
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) and enforces the allowed MIME types. |
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. File Size Check │
95
- │ (chunked progressive) │
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 Detection │
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. Content Analysis │
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
- │ 8. ResourceMonitor END │
138
+ │ 9. ResourceMonitor END │
122
139
  └─────────────┬───────────────┘
123
140
  │
124
141
  ▼
125
- ┌─────────────────────────────┐
126
- │ 9. Audit: VALIDATION_SUCCESS│
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
- │ └── GPX/TCX: XML header │
249
- │ (<?xml) │
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
- | File size check | Chunked reads (`chunk_size`, 64 KB default) | Progressive, discarded after counting |
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
- - [ ] Allowed extensions and MIME types reviewed and narrowed
51
- to only what your application accepts.
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 (invalid limit types,
55
- non-positive limits, empty or uppercase extension entries,
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`) and content threats
92
- (`ContentThreatError`) are `FileValidationError` subclasses.
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 validated MIME type, not 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) and on exit,
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/jpg, image/png",
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/jpg", "image/png"],
135
+ "allowed_mimes": ["image/jpeg", "image/png", "image/webp"],
136
136
  "error_code": "MIME_TYPE_MISMATCH"
137
137
  }
138
138
  ```