safeuploads 3.0.0__tar.gz → 4.0.1__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.1}/.github/workflows/publish-safeuploads.yml +1 -1
  2. {safeuploads-3.0.0 → safeuploads-4.0.1}/CHANGELOG.md +47 -0
  3. {safeuploads-3.0.0 → safeuploads-4.0.1}/PKG-INFO +20 -7
  4. {safeuploads-3.0.0 → safeuploads-4.0.1}/README.md +19 -6
  5. {safeuploads-3.0.0 → safeuploads-4.0.1}/docs/index.md +19 -6
  6. {safeuploads-3.0.0 → safeuploads-4.0.1}/docs/security/architecture.md +43 -18
  7. {safeuploads-3.0.0 → safeuploads-4.0.1}/docs/security/integration-checklist.md +46 -10
  8. {safeuploads-3.0.0 → safeuploads-4.0.1}/docs/security/threat-model.md +79 -2
  9. {safeuploads-3.0.0 → safeuploads-4.0.1}/examples/README.md +2 -2
  10. {safeuploads-3.0.0 → safeuploads-4.0.1}/examples/fastapi_example.py +27 -19
  11. {safeuploads-3.0.0 → safeuploads-4.0.1}/pyproject.toml +2 -2
  12. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/__init__.py +6 -1
  13. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/config.py +206 -144
  14. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/exceptions.py +48 -4
  15. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/file_validator.py +198 -296
  16. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/utils.py +18 -2
  17. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/__init__.py +4 -2
  18. safeuploads-4.0.1/safeuploads/validators/image_dimension_validator.py +336 -0
  19. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/mime_validator.py +44 -15
  20. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/signature_validator.py +9 -0
  21. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/conftest.py +18 -29
  22. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/fuzz/test_fuzz_images.py +52 -1
  23. safeuploads-4.0.1/tests/images.py +188 -0
  24. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/inspectors/test_content_inspector.py +5 -17
  25. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_config.py +138 -81
  26. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_config_validation.py +122 -22
  27. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_exceptions.py +45 -0
  28. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_file_validator.py +202 -157
  29. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_performance.py +4 -34
  30. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_sync_validation.py +171 -19
  31. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_validate_activity_file.py +3 -2
  32. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_extension_validator.py +10 -8
  33. safeuploads-4.0.1/tests/validators/test_image_dimension_validator.py +291 -0
  34. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_mime_validator.py +75 -7
  35. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_signature_validator.py +37 -1
  36. {safeuploads-3.0.0 → safeuploads-4.0.1}/uv.lock +501 -459
  37. {safeuploads-3.0.0 → safeuploads-4.0.1}/.agents/skills/conventional-commits/SKILL.md +0 -0
  38. {safeuploads-3.0.0 → safeuploads-4.0.1}/.agents/skills/conventional-commits/references/pull-requests.md +0 -0
  39. {safeuploads-3.0.0 → safeuploads-4.0.1}/.agents/skills/debugging/SKILL.md +0 -0
  40. {safeuploads-3.0.0 → safeuploads-4.0.1}/.agents/skills/safeuploads-library/SKILL.md +0 -0
  41. {safeuploads-3.0.0 → safeuploads-4.0.1}/.agents/skills/safeuploads-library/references/code-standards.md +0 -0
  42. {safeuploads-3.0.0 → safeuploads-4.0.1}/.agents/skills/safeuploads-library/references/security.md +0 -0
  43. {safeuploads-3.0.0 → safeuploads-4.0.1}/.agents/skills/safeuploads-library/references/testing.md +0 -0
  44. {safeuploads-3.0.0 → safeuploads-4.0.1}/.agents/skills/skill-authoring/SKILL.md +0 -0
  45. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/CODEOWNERS +0 -0
  46. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/FUNDING.yml +0 -0
  47. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  48. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  49. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  50. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  51. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/copilot-instructions.md +0 -0
  52. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/dependabot.yml +0 -0
  53. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/scripts/check_conventional_commits.py +0 -0
  54. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/scripts/smoke_import.py +0 -0
  55. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/workflows/audit-safeuploads.yml +0 -0
  56. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/workflows/conventional-commits.yml +0 -0
  57. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/workflows/docs.yml +0 -0
  58. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/workflows/fuzz.yml +0 -0
  59. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/workflows/lint-and-test.yml +0 -0
  60. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/workflows/mirror.yml +0 -0
  61. {safeuploads-3.0.0 → safeuploads-4.0.1}/.github/workflows/test-matrix-safeuploads.yml +0 -0
  62. {safeuploads-3.0.0 → safeuploads-4.0.1}/.gitignore +0 -0
  63. {safeuploads-3.0.0 → safeuploads-4.0.1}/AGENTS.md +0 -0
  64. {safeuploads-3.0.0 → safeuploads-4.0.1}/CODE_OF_CONDUCT.md +0 -0
  65. {safeuploads-3.0.0 → safeuploads-4.0.1}/CONTRIBUTING.md +0 -0
  66. {safeuploads-3.0.0 → safeuploads-4.0.1}/LICENSE.md +0 -0
  67. {safeuploads-3.0.0 → safeuploads-4.0.1}/SECURITY.md +0 -0
  68. {safeuploads-3.0.0 → safeuploads-4.0.1}/docs/api.md +0 -0
  69. {safeuploads-3.0.0 → safeuploads-4.0.1}/docs/rate-limiting.md +0 -0
  70. {safeuploads-3.0.0 → safeuploads-4.0.1}/mkdocs.yml +0 -0
  71. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/audit.py +0 -0
  72. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/enums.py +0 -0
  73. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/inspectors/__init__.py +0 -0
  74. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/inspectors/base.py +0 -0
  75. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/inspectors/content_inspector.py +0 -0
  76. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/inspectors/gzip_inspector.py +0 -0
  77. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/inspectors/zip_inspector.py +0 -0
  78. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/protocols.py +0 -0
  79. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/py.typed +0 -0
  80. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/base.py +0 -0
  81. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/compression_validator.py +0 -0
  82. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/extension_validator.py +0 -0
  83. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/filename_validator.py +0 -0
  84. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/fit_validator.py +0 -0
  85. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/unicode_validator.py +0 -0
  86. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/windows_validator.py +0 -0
  87. {safeuploads-3.0.0 → safeuploads-4.0.1}/safeuploads/validators/xml_validator.py +0 -0
  88. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/__init__.py +0 -0
  89. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/fuzz/__init__.py +0 -0
  90. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/fuzz/helpers.py +0 -0
  91. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/fuzz/test_fuzz_config.py +0 -0
  92. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/fuzz/test_fuzz_filenames.py +0 -0
  93. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/fuzz/test_fuzz_zip.py +0 -0
  94. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/inspectors/__init__.py +0 -0
  95. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/inspectors/test_gzip_inspector.py +0 -0
  96. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/inspectors/test_zip_inspector.py +0 -0
  97. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_audit.py +0 -0
  98. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_enums.py +0 -0
  99. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_fastapi_integration.py +0 -0
  100. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_utils.py +0 -0
  101. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/test_validate_gzip_file.py +0 -0
  102. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/__init__.py +0 -0
  103. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_compression_validator.py +0 -0
  104. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_filename_validator.py +0 -0
  105. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_fit_validator.py +0 -0
  106. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_unicode_validator.py +0 -0
  107. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_windows_validator.py +0 -0
  108. {safeuploads-3.0.0 → safeuploads-4.0.1}/tests/validators/test_xml_validator.py +0 -0
@@ -159,7 +159,7 @@ jobs:
159
159
  # This job never checks the repository out, so there is no
160
160
  # pyproject.toml to read `required-version` from; keep this in step
161
161
  # with the floor declared there.
162
- version: "0.13.5"
162
+ version: "0.13.8"
163
163
  enable-cache: false
164
164
 
165
165
  - name: Download build artifacts
@@ -4,6 +4,51 @@ 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.1] - 2026-11-15
8
+
9
+ ### Changed
10
+
11
+ - Updated all dependency groups to their latest versions, including `opentelemetry-api` 1.45.2, which fixes CVE-2026-126360 in the locked docs toolchain.
12
+ - Updated uv to 0.13.8.
13
+
14
+ ## [4.0.0] - 2026-11-06
15
+
16
+ ### Added
17
+
18
+ - 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`.
19
+ - `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.
20
+ - `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.
21
+ - 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`).
22
+ - `ImageDimensionError`, exported from `safeuploads`: a `FileValidationError` with `width`, `height` and `max_pixels`, raised with the new `ErrorCode.IMAGE_DIMENSIONS_EXCEEDED`.
23
+ - `ErrorCode.IMAGE_INVALID_STRUCTURE`, raised with `FileSignatureError` when an image's dimension header is missing, truncated or invalid.
24
+ - `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.
25
+
26
+ ### Changed
27
+
28
+ - **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`.
29
+ - **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"`.
30
+ - **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`.
31
+ - **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`.
32
+ - 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.
33
+ - **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.
34
+ - **Breaking:** `MimeTypeValidator.enforce()` takes an allowed-types mapping instead of a set of MIME types and checks the filename's extension against it.
35
+ - **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`.
36
+
37
+ ### Removed
38
+
39
+ - **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
40
+
41
+ ```python
42
+ config.ALLOWED_IMAGE_MIMES = frozenset({"image/png"})
43
+ config.ALLOWED_IMAGE_EXTENSIONS = frozenset({".png"})
44
+ ```
45
+
46
+ to
47
+
48
+ ```python
49
+ config.ALLOWED_IMAGE_TYPES = {"image/png": frozenset({".png"})}
50
+ ```
51
+
7
52
  ## [3.0.0] - 2026-11-05
8
53
 
9
54
  ### Added
@@ -242,6 +287,8 @@ Initial release.
242
287
  - **MIME type verification:** magic-number validation for common file types.
243
288
  - Framework-agnostic async validation, a rich exception hierarchy with machine-readable error codes, secure defaults, and full type hints.
244
289
 
290
+ [4.0.1]: https://github.com/endurain-project/safeuploads/compare/v4.0.0...v4.0.1
291
+ [4.0.0]: https://github.com/endurain-project/safeuploads/compare/v3.0.0...v4.0.0
245
292
  [3.0.0]: https://github.com/endurain-project/safeuploads/compare/v2.2.0...v3.0.0
246
293
  [2.2.0]: https://github.com/endurain-project/safeuploads/compare/v2.1.0...v2.2.0
247
294
  [2.1.0]: https://github.com/endurain-project/safeuploads/compare/v2.0.0...v2.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: safeuploads
3
- Version: 3.0.0
3
+ Version: 4.0.1
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`