safeuploads 2.2.0__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/references/code-standards.md +1 -1
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/workflows/test-matrix-safeuploads.yml +2 -2
- {safeuploads-2.2.0 → safeuploads-3.0.0}/AGENTS.md +3 -3
- {safeuploads-2.2.0 → safeuploads-3.0.0}/CHANGELOG.md +21 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/CONTRIBUTING.md +1 -1
- {safeuploads-2.2.0 → safeuploads-3.0.0}/PKG-INFO +6 -4
- {safeuploads-2.2.0 → safeuploads-3.0.0}/README.md +4 -2
- {safeuploads-2.2.0 → safeuploads-3.0.0}/docs/index.md +1 -1
- {safeuploads-2.2.0 → safeuploads-3.0.0}/docs/security/architecture.md +6 -2
- {safeuploads-2.2.0 → safeuploads-3.0.0}/docs/security/threat-model.md +41 -4
- {safeuploads-2.2.0 → safeuploads-3.0.0}/pyproject.toml +5 -5
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/__init__.py +0 -11
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/exceptions.py +0 -43
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/inspectors/zip_inspector.py +56 -4
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/extension_validator.py +8 -4
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/inspectors/test_zip_inspector.py +114 -1
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_exceptions.py +10 -32
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_file_validator.py +18 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_extension_validator.py +43 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/uv.lock +239 -5
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.agents/skills/conventional-commits/SKILL.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.agents/skills/conventional-commits/references/pull-requests.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.agents/skills/debugging/SKILL.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/SKILL.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/references/security.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/references/testing.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.agents/skills/skill-authoring/SKILL.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/CODEOWNERS +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/FUNDING.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/copilot-instructions.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/dependabot.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/scripts/check_conventional_commits.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/scripts/smoke_import.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/workflows/audit-safeuploads.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/workflows/conventional-commits.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/workflows/docs.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/workflows/fuzz.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/workflows/lint-and-test.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/workflows/mirror.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.github/workflows/publish-safeuploads.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/.gitignore +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/CODE_OF_CONDUCT.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/LICENSE.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/SECURITY.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/docs/api.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/docs/rate-limiting.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/docs/security/integration-checklist.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/examples/README.md +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/examples/fastapi_example.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/mkdocs.yml +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/audit.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/config.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/enums.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/file_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/inspectors/__init__.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/inspectors/base.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/inspectors/content_inspector.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/inspectors/gzip_inspector.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/protocols.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/py.typed +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/utils.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/__init__.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/base.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/compression_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/filename_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/fit_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/mime_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/signature_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/unicode_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/windows_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/safeuploads/validators/xml_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/__init__.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/conftest.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/fuzz/__init__.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/fuzz/helpers.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/fuzz/test_fuzz_config.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/fuzz/test_fuzz_filenames.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/fuzz/test_fuzz_images.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/fuzz/test_fuzz_zip.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/inspectors/__init__.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/inspectors/test_content_inspector.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/inspectors/test_gzip_inspector.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_audit.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_config.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_config_validation.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_enums.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_fastapi_integration.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_performance.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_sync_validation.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_utils.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_validate_activity_file.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/test_validate_gzip_file.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/__init__.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_compression_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_filename_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_fit_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_mime_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_signature_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_unicode_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_windows_validator.py +0 -0
- {safeuploads-2.2.0 → safeuploads-3.0.0}/tests/validators/test_xml_validator.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Code standards
|
|
2
2
|
|
|
3
3
|
## Python
|
|
4
|
-
- Python 3.
|
|
4
|
+
- Python 3.11 syntax and standard library (`requires-python = ">=3.11"`): `int | None`, `list[str]`, `dict[str, Any]`; no `typing.Optional/List/Dict`, and no 3.12+ features such as PEP 695 type parameters, `type` aliases or `typing.override`. The 3.11 CI job is the check.
|
|
5
5
|
- Lines up to 79 characters, enforced by `ruff format` (`line-length = 79`); keep comments and docstrings within it too.
|
|
6
6
|
- Imports grouped stdlib / third-party / local; `TYPE_CHECKING` to avoid cycles.
|
|
7
7
|
- Every module has a top-level docstring.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
name: Test matrix safeuploads
|
|
2
2
|
|
|
3
3
|
# Portability matrix on top of the fast default `Lint & Test` workflow:
|
|
4
|
-
# * every supported Python version (3.
|
|
4
|
+
# * every supported Python version (3.11 to 3.15) with the full
|
|
5
5
|
# dependency set (dev group + fastapi extra), proving the suite
|
|
6
6
|
# passes on every Python the package declares support for; and
|
|
7
7
|
# * a bare `import safeuploads` with only the base dependencies
|
|
@@ -40,7 +40,7 @@ jobs:
|
|
|
40
40
|
strategy:
|
|
41
41
|
fail-fast: false
|
|
42
42
|
matrix:
|
|
43
|
-
python: ["3.13", "3.14", "3.15"]
|
|
43
|
+
python: ["3.11", "3.12", "3.13", "3.14", "3.15"]
|
|
44
44
|
steps:
|
|
45
45
|
- name: Checkout code
|
|
46
46
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# safeuploads
|
|
2
2
|
|
|
3
|
-
Secure file upload validation for Python 3.
|
|
3
|
+
Secure file upload validation for Python 3.11+ applications.
|
|
4
4
|
Catches dangerous filenames, malicious extensions, Windows reserved names, compression bombs, polyglots and unsafe XML before an upload is accepted.
|
|
5
5
|
Runtime dependencies are minimal (`python-magic`, `defusedxml`; FastAPI behind the `fastapi` extra).
|
|
6
6
|
Published to PyPI; the public API is governed by SemVer.
|
|
@@ -33,7 +33,7 @@ docs/ Documentation site (mkdocs)
|
|
|
33
33
|
|
|
34
34
|
## Commands
|
|
35
35
|
|
|
36
|
-
From the repository root (Python 3.
|
|
36
|
+
From the repository root (Python 3.11+, `uv`, system `libmagic`):
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
uv sync --dev --extra fastapi
|
|
@@ -97,4 +97,4 @@ uv run pytest
|
|
|
97
97
|
- Never build a filesystem path from an uploaded filename.
|
|
98
98
|
- Logging uses `logging.getLogger(__name__)` and the `SecurityAuditLogger`; never log file contents.
|
|
99
99
|
- Comments and docstrings state what the code does now, not what it used to do.
|
|
100
|
-
- Python 3.
|
|
100
|
+
- Python 3.11 syntax and standard library (no 3.12+ features), 79-character lines (`pyproject.toml`), PEP 257 docstrings with `Args`, `Returns`, `Raises` where applicable.
|
|
@@ -4,6 +4,25 @@ 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
|
+
## [3.0.0] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Support for Python 3.11 and 3.12; `requires-python` is now `>=3.11` and CI tests 3.11 to 3.15. A Python version stays supported until its CPython end of life and is dropped in the next minor release after that. The Unicode checks use the running Python's Unicode database, so a character assigned after that database's version is rejected as unassigned (for example U+1FAE8 on 3.11).
|
|
12
|
+
|
|
13
|
+
### Removed
|
|
14
|
+
|
|
15
|
+
- **Breaking:** the `ConfigValidationError` alias, deprecated in 2.2.0, is removed from `safeuploads` and `safeuploads.exceptions`. Use `ConfigValidationIssue`.
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
|
|
19
|
+
- ZIP entry names now get the filename checks a top-level upload gets ([GHSA-jjqv-c38c-fjf3](https://github.com/endurain-project/safeuploads/security/advisories/GHSA-jjqv-c38c-fjf3)). `ZipContentInspector` checked entries only against `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES` and `SYSTEM_FILES`, so an entry such as `invoice.docm`, `a.hta`, `CON.txt` or a name with a right-to-left override was accepted although the same name is rejected as an upload. Each entry name is now checked against `DANGEROUS_UNICODE_CHARS` and the rejected Unicode categories, each path component against `WINDOWS_RESERVED_NAMES`, and a file's base name against `BLOCKED_EXTENSIONS` and `COMPOUND_BLOCKED_EXTENSIONS`, on the NFC and NFKC forms, all taken from `FileSecurityConfig`. A failing entry raises `ZipContentError` with `ZIP_CONTENT_THREAT`.
|
|
20
|
+
- The blocked-extension check ignores trailing dots and spaces, as Windows drops them when it saves a file. A filename such as `run.exe ` or `invoice.docm` followed by U+3000 or U+00A0 (which NFKC folds to a space) was accepted as an upload or a ZIP entry; it is now rejected with `EXTENSION_BLOCKED`.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Breaking:** the ZIP entry name checks under Security reject archives that 2.2.0 accepted under the default configuration, for example archives with `.docm`, `.xlsm`, `.hta`, `.min.css` or `CON.txt` entries. Uploads and entries whose name ends in a blocked extension followed by spaces, such as `run.exe `, are rejected as well. An entry named `.jar`, `.war` or `.ear` whose content is a ZIP now reports `ZIP_CONTENT_THREAT` first instead of `ZIP_NESTED_ARCHIVE`.
|
|
25
|
+
|
|
7
26
|
## [2.2.0] - 2026-11-05
|
|
8
27
|
|
|
9
28
|
### Added
|
|
@@ -223,6 +242,8 @@ Initial release.
|
|
|
223
242
|
- **MIME type verification:** magic-number validation for common file types.
|
|
224
243
|
- Framework-agnostic async validation, a rich exception hierarchy with machine-readable error codes, secure defaults, and full type hints.
|
|
225
244
|
|
|
245
|
+
[3.0.0]: https://github.com/endurain-project/safeuploads/compare/v2.2.0...v3.0.0
|
|
246
|
+
[2.2.0]: https://github.com/endurain-project/safeuploads/compare/v2.1.0...v2.2.0
|
|
226
247
|
[2.1.0]: https://github.com/endurain-project/safeuploads/compare/v2.0.0...v2.1.0
|
|
227
248
|
[2.0.0]: https://github.com/endurain-project/safeuploads/compare/v1.1.3...v2.0.0
|
|
228
249
|
[1.1.3]: https://github.com/endurain-project/safeuploads/compare/v1.1.2...v1.1.3
|
|
@@ -72,7 +72,7 @@ PRs that are too large to review efficiently will be asked to be split before th
|
|
|
72
72
|
docs: update the integration checklist
|
|
73
73
|
```
|
|
74
74
|
Commit subjects and PR titles are validated in CI; see `.agents/skills/conventional-commits/SKILL.md` for the allowed types and scopes.
|
|
75
|
-
5. **Run the checks** from the repository root (Python 3.
|
|
75
|
+
5. **Run the checks** from the repository root (Python 3.11+, [uv](https://docs.astral.sh/uv/) and the system `libmagic`):
|
|
76
76
|
```bash
|
|
77
77
|
uv sync --dev --extra fastapi
|
|
78
78
|
uv run ruff check safeuploads tests examples
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: safeuploads
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.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
|
|
7
7
|
License-File: LICENSE.md
|
|
8
|
-
Requires-Python: >=3.
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
9
|
Requires-Dist: defusedxml<1.0.0,>=0.7.1
|
|
10
10
|
Requires-Dist: python-magic<0.5.0,>=0.4.27
|
|
11
11
|
Provides-Extra: fastapi
|
|
@@ -18,11 +18,11 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
[](https://github.com/endurain-project/safeuploads/releases)
|
|
19
19
|
[](https://pypi.org/project/safeuploads/)
|
|
20
20
|
[](https://pypi.org/project/safeuploads/)
|
|
21
|
-
[](https://pypi.org/project/safeuploads/)
|
|
22
22
|
[](https://safeuploads.endurain.com/)
|
|
23
23
|
[](https://github.com/endurain-project/safeuploads)
|
|
24
24
|
|
|
25
|
-
Secure file upload validation for Python 3.
|
|
25
|
+
Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload.
|
|
26
26
|
|
|
27
27
|
## Features
|
|
28
28
|
|
|
@@ -46,6 +46,8 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f
|
|
|
46
46
|
pip install safeuploads
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
safeuploads supports Python 3.11 and newer. A Python version stays supported until its CPython end of life and is dropped in the next minor release after that.
|
|
50
|
+
|
|
49
51
|
For FastAPI integration:
|
|
50
52
|
```bash
|
|
51
53
|
pip install safeuploads[fastapi]
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
[](https://github.com/endurain-project/safeuploads/releases)
|
|
5
5
|
[](https://pypi.org/project/safeuploads/)
|
|
6
6
|
[](https://pypi.org/project/safeuploads/)
|
|
7
|
-
[](https://pypi.org/project/safeuploads/)
|
|
8
8
|
[](https://safeuploads.endurain.com/)
|
|
9
9
|
[](https://github.com/endurain-project/safeuploads)
|
|
10
10
|
|
|
11
|
-
Secure file upload validation for Python 3.
|
|
11
|
+
Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload.
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
@@ -32,6 +32,8 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f
|
|
|
32
32
|
pip install safeuploads
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
safeuploads supports Python 3.11 and newer. A Python version stays supported until its CPython end of life and is dropped in the next minor release after that.
|
|
36
|
+
|
|
35
37
|
For FastAPI integration:
|
|
36
38
|
```bash
|
|
37
39
|
pip install safeuploads[fastapi]
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
</a>
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
|
-
Secure file upload validation for Python 3.
|
|
15
|
+
Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload.
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
@@ -51,7 +51,7 @@ safeuploads/
|
|
|
51
51
|
| `CompressionSecurityValidator` | Checks compression ratio, uncompressed size, entry count, and nested archive limits, and that the listed entries fill the file exactly. |
|
|
52
52
|
| `XmlSecurityValidator` | Parses XML with `defusedxml`, blocking DTDs and external entities (raises `XmlSecurityError`). |
|
|
53
53
|
| `FitSecurityValidator` | Checks the FIT container: headers, header CRC, data size, CRC-16 per section, and that the sections cover the file exactly. |
|
|
54
|
-
| `ZipContentInspector` | Iterates ZIP entries checking for traversal, executables, scripts, symlinks, and recursive structures. With `allow_nested_archives`, inspects every inner ZIP like the top level. |
|
|
54
|
+
| `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
55
|
| `GzipContentInspector` | Streams gzip decompression, checking ratio and size progressively. |
|
|
56
56
|
| `ContentSecurityInspector` | Scans raw bytes for structurally verified executable headers, web shells, and polyglot patterns (image and activity uploads). |
|
|
57
57
|
| `ResourceMonitor` | Context manager enforcing wall-clock time and peak-RSS memory limits at loop boundaries and on exit. |
|
|
@@ -181,7 +181,11 @@ UploadFile
|
|
|
181
181
|
│ │ ├── Null bytes │
|
|
182
182
|
│ │ ├── Path traversal │
|
|
183
183
|
│ │ ├── Absolute paths │
|
|
184
|
-
│ │
|
|
184
|
+
│ │ ├── Symlinks │
|
|
185
|
+
│ │ └── Top-level name │
|
|
186
|
+
│ │ checks (Unicode, │
|
|
187
|
+
│ │ Windows names, │
|
|
188
|
+
│ │ blocked exts) │
|
|
185
189
|
│ ├── Dangerous entry types│
|
|
186
190
|
│ ├── Binary signatures │
|
|
187
191
|
│ ├── Nested archive magic │
|
|
@@ -59,6 +59,11 @@ is `.exe`.
|
|
|
59
59
|
rejected with `UnicodeSecurityError`; none is stripped. A
|
|
60
60
|
filename containing an emoji ZWJ sequence is therefore
|
|
61
61
|
rejected.
|
|
62
|
+
- Categories come from the running Python's Unicode database
|
|
63
|
+
(14.0 on Python 3.11, 15.0 on 3.12, 15.1 on 3.13), so a
|
|
64
|
+
character assigned in a later Unicode version is unassigned
|
|
65
|
+
(Cn) and rejected on an older Python. Older versions only
|
|
66
|
+
reject more.
|
|
62
67
|
- The Windows reserved-name and extension checks run on the NFC
|
|
63
68
|
name and on its NFKC form, so fullwidth letters or
|
|
64
69
|
superscript digits cannot disguise `CON`, `COM1` or `.php`.
|
|
@@ -99,6 +104,11 @@ improperly.
|
|
|
99
104
|
files, drivers, themes, and help files.
|
|
100
105
|
- Compound extensions (`.tar.gz`, `.user.js`, `.min.css`) are
|
|
101
106
|
checked via `CompoundExtensionCategory`.
|
|
107
|
+
- Trailing dots and spaces are ignored by the blocked-extension
|
|
108
|
+
check, because Windows drops them when it saves a file. The
|
|
109
|
+
check also runs on the NFKC form, so `run.exe ` or
|
|
110
|
+
`invoice.docm` followed by U+3000 (an ideographic space that
|
|
111
|
+
NFKC folds to a space) is rejected.
|
|
102
112
|
- Allowed extensions are validated against a configurable
|
|
103
113
|
allowlist per file type.
|
|
104
114
|
|
|
@@ -285,6 +295,32 @@ outside the extraction directory (Zip Slip).
|
|
|
285
295
|
bypasses (CWE-158). Python's `zipfile` truncates `filename`
|
|
286
296
|
at the first NUL, so the raw name is needed.
|
|
287
297
|
|
|
298
|
+
### Spoofed or Blocked Entry Names (CWE-184, CWE-434)
|
|
299
|
+
|
|
300
|
+
**Attack:** A file refused as a standalone upload (a
|
|
301
|
+
macro-enabled Office document, an `.hta`, a name with a
|
|
302
|
+
right-to-left override, `CON.txt`) is wrapped in a ZIP so that a
|
|
303
|
+
host extracting or listing the entries stores it anyway.
|
|
304
|
+
|
|
305
|
+
**Mitigations:**
|
|
306
|
+
|
|
307
|
+
- Every entry name gets the filename checks a top-level upload
|
|
308
|
+
gets, driven by the same `FileSecurityConfig` fields, so a
|
|
309
|
+
configuration that extends a list covers entries too:
|
|
310
|
+
- the whole name is checked against `DANGEROUS_UNICODE_CHARS`
|
|
311
|
+
and the rejected Unicode categories, and its NFKC form must
|
|
312
|
+
not introduce a path separator or `..`;
|
|
313
|
+
- every path component is checked against
|
|
314
|
+
`WINDOWS_RESERVED_NAMES`;
|
|
315
|
+
- a file's base name is checked against `BLOCKED_EXTENSIONS`
|
|
316
|
+
(every extension in the name) and
|
|
317
|
+
`COMPOUND_BLOCKED_EXTENSIONS`.
|
|
318
|
+
- The reserved-name and extension checks run on the NFC name and
|
|
319
|
+
its NFKC form, after stripping trailing dots and spaces that
|
|
320
|
+
Windows drops on extraction.
|
|
321
|
+
- A failing name is reported as `ZIP_CONTENT_THREAT`; the message
|
|
322
|
+
shows the name with control and bidi characters escaped.
|
|
323
|
+
|
|
288
324
|
### Executable Content in ZIP
|
|
289
325
|
|
|
290
326
|
**Attack:** Executables, scripts, system files, or shortcuts
|
|
@@ -292,10 +328,11 @@ hidden inside ZIP archives.
|
|
|
292
328
|
|
|
293
329
|
**Mitigations:**
|
|
294
330
|
|
|
295
|
-
-
|
|
296
|
-
and spaces, is checked
|
|
297
|
-
`ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`,
|
|
298
|
-
`SYSTEM_FILES
|
|
331
|
+
- Besides `BLOCKED_EXTENSIONS` (see above), the entry's final
|
|
332
|
+
extension, after stripping trailing dots and spaces, is checked
|
|
333
|
+
against `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`,
|
|
334
|
+
and `SYSTEM_FILES`, which also cover configuration files such
|
|
335
|
+
as `.ini`, `.cfg` and `.conf`.
|
|
299
336
|
- Binary content is scanned for executable magic bytes from
|
|
300
337
|
`SuspiciousFilePattern.EXECUTABLE_SIGNATURES`. The first 512
|
|
301
338
|
bytes of each entry are read for this scan.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "safeuploads"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "A comprehensive file security system for validating uploads and preventing attacks"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "João Vitória Silva",email = "joao@endurain.com"}
|
|
7
7
|
]
|
|
8
8
|
license = {text = "MIT"}
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"python-magic>=0.4.27,<0.5.0",
|
|
13
13
|
"defusedxml>=0.7.1,<1.0.0",
|
|
@@ -46,7 +46,7 @@ exclude-newer = "7 days"
|
|
|
46
46
|
|
|
47
47
|
[tool.ruff]
|
|
48
48
|
line-length = 79
|
|
49
|
-
target-version = "
|
|
49
|
+
target-version = "py311"
|
|
50
50
|
|
|
51
51
|
[tool.ruff.lint]
|
|
52
52
|
select = [
|
|
@@ -155,7 +155,7 @@ exclude_lines = [
|
|
|
155
155
|
]
|
|
156
156
|
|
|
157
157
|
[tool.mypy]
|
|
158
|
-
python_version = "3.
|
|
158
|
+
python_version = "3.11"
|
|
159
159
|
incremental = false
|
|
160
160
|
# strict = true enables the full strict bundle, including
|
|
161
161
|
# disallow_any_generics, warn_return_any, disallow_untyped_calls,
|
|
@@ -174,7 +174,7 @@ ignore_missing_imports = true
|
|
|
174
174
|
# ``ty`` runs beside mypy as an advisory, non-blocking CI job (it is pre-1.0);
|
|
175
175
|
# mypy stays the gate. Same scope as ``[tool.mypy]``.
|
|
176
176
|
[tool.ty.environment]
|
|
177
|
-
python-version = "3.
|
|
177
|
+
python-version = "3.11"
|
|
178
178
|
|
|
179
179
|
[tool.ty.src]
|
|
180
180
|
include = ["safeuploads"]
|
|
@@ -6,7 +6,6 @@ validating uploads and preventing attacks.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
-
from typing import TYPE_CHECKING
|
|
10
9
|
|
|
11
10
|
from .audit import (
|
|
12
11
|
AuditEvent,
|
|
@@ -76,16 +75,6 @@ from .validators import (
|
|
|
76
75
|
XmlSecurityValidator,
|
|
77
76
|
)
|
|
78
77
|
|
|
79
|
-
if TYPE_CHECKING:
|
|
80
|
-
# Deprecated alias, resolved by ``__getattr__`` at runtime
|
|
81
|
-
from .exceptions import ConfigValidationError as ConfigValidationError
|
|
82
|
-
else:
|
|
83
|
-
from .exceptions import _resolve_deprecated
|
|
84
|
-
|
|
85
|
-
def __getattr__(name: str) -> object:
|
|
86
|
-
"""Resolve deprecated package attributes (PEP 562)."""
|
|
87
|
-
return _resolve_deprecated(__name__, name)
|
|
88
|
-
|
|
89
78
|
# Configuration is validated explicitly rather than at import
|
|
90
79
|
# time: FileValidator validates its config on construction, and
|
|
91
80
|
# callers can invoke FileSecurityConfig.validate_and_report() or
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"""File security exception classes and error codes."""
|
|
2
2
|
|
|
3
|
-
import warnings
|
|
4
3
|
from dataclasses import dataclass
|
|
5
4
|
from enum import StrEnum
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
7
5
|
|
|
8
6
|
# ============================================================================
|
|
9
7
|
# Configuration Validation
|
|
@@ -34,47 +32,6 @@ class ConfigValidationIssue:
|
|
|
34
32
|
recommendation: str = ""
|
|
35
33
|
|
|
36
34
|
|
|
37
|
-
_DEPRECATED_NAMES = {"ConfigValidationError": "ConfigValidationIssue"}
|
|
38
|
-
|
|
39
|
-
if TYPE_CHECKING:
|
|
40
|
-
# Deprecated alias; at runtime ``__getattr__`` resolves it with a
|
|
41
|
-
# DeprecationWarning. Removed in 3.0.
|
|
42
|
-
ConfigValidationError = ConfigValidationIssue
|
|
43
|
-
else:
|
|
44
|
-
|
|
45
|
-
def _resolve_deprecated(module: str, name: str) -> object:
|
|
46
|
-
"""
|
|
47
|
-
Return the object a deprecated name stands for, with a warning.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
module: Name of the module the attribute was looked up on.
|
|
51
|
-
name: Attribute name being looked up.
|
|
52
|
-
|
|
53
|
-
Returns:
|
|
54
|
-
The object that replaces the deprecated name.
|
|
55
|
-
|
|
56
|
-
Raises:
|
|
57
|
-
AttributeError: ``name`` is not a deprecated name.
|
|
58
|
-
"""
|
|
59
|
-
new_name = _DEPRECATED_NAMES.get(name)
|
|
60
|
-
if new_name is None:
|
|
61
|
-
raise AttributeError(
|
|
62
|
-
f"module {module!r} has no attribute {name!r}"
|
|
63
|
-
)
|
|
64
|
-
warnings.warn(
|
|
65
|
-
f"{name} is deprecated and will be removed in safeuploads"
|
|
66
|
-
f" 3.0; use {new_name}",
|
|
67
|
-
DeprecationWarning,
|
|
68
|
-
# Skip this helper and the module ``__getattr__``
|
|
69
|
-
stacklevel=3,
|
|
70
|
-
)
|
|
71
|
-
return globals()[new_name]
|
|
72
|
-
|
|
73
|
-
def __getattr__(name: str) -> object:
|
|
74
|
-
"""Resolve deprecated module attributes (PEP 562)."""
|
|
75
|
-
return _resolve_deprecated(__name__, name)
|
|
76
|
-
|
|
77
|
-
|
|
78
35
|
class FileSecurityConfigurationError(Exception):
|
|
79
36
|
"""
|
|
80
37
|
Configuration validation failed with aggregated errors.
|
|
@@ -20,6 +20,7 @@ from ..enums import (
|
|
|
20
20
|
from ..exceptions import (
|
|
21
21
|
CompressionSecurityError,
|
|
22
22
|
ErrorCode,
|
|
23
|
+
FilenameSecurityError,
|
|
23
24
|
FileProcessingError,
|
|
24
25
|
ResourceLimitError,
|
|
25
26
|
ZipBombError,
|
|
@@ -35,6 +36,9 @@ from ..validators.compression_validator import (
|
|
|
35
36
|
ZIP_PARSE_ERRORS,
|
|
36
37
|
CompressionSecurityValidator,
|
|
37
38
|
)
|
|
39
|
+
from ..validators.extension_validator import ExtensionSecurityValidator
|
|
40
|
+
from ..validators.unicode_validator import UnicodeSecurityValidator
|
|
41
|
+
from ..validators.windows_validator import WindowsSecurityValidator
|
|
38
42
|
from .base import BaseInspector
|
|
39
43
|
|
|
40
44
|
if TYPE_CHECKING:
|
|
@@ -127,6 +131,10 @@ class ZipContentInspector(BaseInspector):
|
|
|
127
131
|
# Nested archives get the same compression checks as the
|
|
128
132
|
# top-level archive
|
|
129
133
|
self._compression = CompressionSecurityValidator(config)
|
|
134
|
+
# Entry names get the filename checks a top-level upload gets
|
|
135
|
+
self._unicode = UnicodeSecurityValidator(config)
|
|
136
|
+
self._windows = WindowsSecurityValidator(config)
|
|
137
|
+
self._extensions = ExtensionSecurityValidator(config)
|
|
130
138
|
|
|
131
139
|
def inspect_zip_content(self, file_obj: SeekableFile) -> None:
|
|
132
140
|
"""
|
|
@@ -278,8 +286,9 @@ class ZipContentInspector(BaseInspector):
|
|
|
278
286
|
|
|
279
287
|
Checks the raw name for null bytes, then the name for traversal,
|
|
280
288
|
absolute paths, symlinks, length limits, suspicious patterns,
|
|
281
|
-
nested archive extensions
|
|
282
|
-
content is inspected when
|
|
289
|
+
nested archive extensions, the top-level filename checks and
|
|
290
|
+
dangerous file extensions. The content is inspected when
|
|
291
|
+
``scan_zip_content`` is enabled.
|
|
283
292
|
|
|
284
293
|
Args:
|
|
285
294
|
entry: ZIP entry metadata.
|
|
@@ -367,13 +376,16 @@ class ZipContentInspector(BaseInspector):
|
|
|
367
376
|
)
|
|
368
377
|
)
|
|
369
378
|
|
|
370
|
-
# 9. Check
|
|
379
|
+
# 9. Check the name as a top-level filename is checked
|
|
380
|
+
threats.extend(self._check_entry_name(entry))
|
|
381
|
+
|
|
382
|
+
# 10. Check for executable, script and system file extensions
|
|
371
383
|
if self._extension(filename) in self._dangerous_exts:
|
|
372
384
|
threats.append(
|
|
373
385
|
_content_threat(f"Dangerous file type detected: '{filename}'")
|
|
374
386
|
)
|
|
375
387
|
|
|
376
|
-
#
|
|
388
|
+
# 11. Check file content if enabled
|
|
377
389
|
# Only first 512 bytes are read, so no size gate needed
|
|
378
390
|
if self.config.limits.scan_zip_content and not entry.is_dir():
|
|
379
391
|
content_threats = self._inspect_entry_content(entry, zip_file)
|
|
@@ -527,6 +539,46 @@ class ZipContentInspector(BaseInspector):
|
|
|
527
539
|
"""
|
|
528
540
|
return self._extension(filename) in self._nested_archive_exts
|
|
529
541
|
|
|
542
|
+
def _check_entry_name(self, entry: zipfile.ZipInfo) -> list[_Threat]:
|
|
543
|
+
"""
|
|
544
|
+
Apply the top-level filename checks to an entry name.
|
|
545
|
+
|
|
546
|
+
The whole name gets the Unicode checks, every path component
|
|
547
|
+
the Windows reserved-name check and a file's base name the
|
|
548
|
+
blocked and compound extension checks, all driven by the
|
|
549
|
+
configuration. Name checks run on the NFC name and on its NFKC
|
|
550
|
+
form; both validators ignore the trailing dots and spaces that
|
|
551
|
+
Windows drops on extraction.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
entry: ZIP entry whose name is checked.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
One threat if the name fails a check, otherwise none.
|
|
558
|
+
"""
|
|
559
|
+
try:
|
|
560
|
+
name = self._unicode.validate_unicode_security(entry.filename)
|
|
561
|
+
self._unicode.compatibility_form(name)
|
|
562
|
+
components = name.replace("\\", "/").split("/")
|
|
563
|
+
for component in components:
|
|
564
|
+
for form in (
|
|
565
|
+
component,
|
|
566
|
+
self._unicode.compatibility_form(component),
|
|
567
|
+
):
|
|
568
|
+
self._windows.validate_windows_reserved_names(form)
|
|
569
|
+
if not entry.is_dir():
|
|
570
|
+
base = components[-1]
|
|
571
|
+
for form in (base, self._unicode.compatibility_form(base)):
|
|
572
|
+
self._extensions.validate_extensions(form)
|
|
573
|
+
except FilenameSecurityError as err:
|
|
574
|
+
# repr escapes control and bidi characters in the message
|
|
575
|
+
return [
|
|
576
|
+
_content_threat(
|
|
577
|
+
f"Unsafe file name {entry.filename!r}: {err.message}"
|
|
578
|
+
)
|
|
579
|
+
]
|
|
580
|
+
return []
|
|
581
|
+
|
|
530
582
|
@staticmethod
|
|
531
583
|
def _extension(filename: str) -> str:
|
|
532
584
|
"""
|
|
@@ -46,6 +46,10 @@ class ExtensionSecurityValidator(BaseValidator):
|
|
|
46
46
|
"""
|
|
47
47
|
Validate filename against blocked extensions.
|
|
48
48
|
|
|
49
|
+
Trailing dots and spaces are ignored, because Windows drops
|
|
50
|
+
them when it saves a file, so ``run.exe .`` is checked as
|
|
51
|
+
``run.exe``.
|
|
52
|
+
|
|
49
53
|
Args:
|
|
50
54
|
filename: Name of the file to validate.
|
|
51
55
|
|
|
@@ -53,10 +57,10 @@ class ExtensionSecurityValidator(BaseValidator):
|
|
|
53
57
|
ExtensionSecurityError: If blocked compound or single
|
|
54
58
|
extension detected in filename.
|
|
55
59
|
"""
|
|
60
|
+
filename_lower = filename.rstrip(". ").lower()
|
|
61
|
+
logger.debug("Validating extensions in filename")
|
|
56
62
|
# Check for compound dangerous extensions first
|
|
57
63
|
# (e.g., .tar.xz, .user.js)
|
|
58
|
-
filename_lower = filename.lower()
|
|
59
|
-
logger.debug("Validating extensions in filename")
|
|
60
64
|
for compound_ext in self._compound_blocked:
|
|
61
65
|
if filename_lower.endswith(compound_ext):
|
|
62
66
|
logger.warning(
|
|
@@ -80,10 +84,10 @@ class ExtensionSecurityValidator(BaseValidator):
|
|
|
80
84
|
)
|
|
81
85
|
|
|
82
86
|
# Check ALL extensions in the filename for dangerous ones
|
|
83
|
-
parts =
|
|
87
|
+
parts = filename_lower.split(".")
|
|
84
88
|
if len(parts) > 1:
|
|
85
89
|
for i in range(1, len(parts)):
|
|
86
|
-
ext = f".{parts[i]
|
|
90
|
+
ext = f".{parts[i]}"
|
|
87
91
|
if ext in self._blocked:
|
|
88
92
|
logger.warning(
|
|
89
93
|
"Dangerous extension detected",
|