upload_guard
Pure-Python upload validation: real file-type detection, extension consistency, SVG XSS sanitising, zip-bomb and path-traversal protection — with zero system dependencies.
python-magic needs libmagic (.so / .dll) and breaks on Alpine, distroless images and Windows dev boxes. Most back-ends also forget that an "image" can be a PHP web-shell, that SVGs run JavaScript, and that a 40 KB zip can expand to 4 PB. upload_guard fixes all of that in one call, using only the standard library.
中文摘要在最後一節。可直接執行的 FastAPI / Flask / Django 範例在 examples/,威脅模型見 SECURITY.md。
Contents
- Quick start
- What it checks
- Install
- Usage
- Framework integration — FastAPI · Django · Flask
- CLI
- Design notes
- Limitations
- 繁體中文簡介
Quick start (30 seconds)
pip install upload_guard
from upload_guard import UploadGuard, UploadRejected
guard = UploadGuard(allowed=["image/*", "application/pdf", ".docx"], max_size=10 * 1024 * 1024)
png = b"\x89PNG\r\n\x1a\n" + bytes(64) # any upload: bytes, path, file object, UploadFile...
result = guard.check(png, filename="cat.png")
print(result.mime, result.safe_filename) # image/png cat.png
try:
guard.check(b"MZ" + bytes(200), filename="cat.png") # a Windows executable renamed to .png
except UploadRejected as exc:
print("REJECTED", exc.codes) # ['extension.mismatch', 'type.blocked', 'type.not_allowed']
In a request handler you would then store result.sanitized or data under result.safe_filename.
What it checks
| Module | What it catches |
|---|---|
| Magic-number sniffer | Reads the first 2 KiB and identifies ~120 formats (PNG/JPEG/WebP/HEIC/AVIF, PDF, legacy and OOXML Office, ODF, EPUB, ZIP/7z/RAR/tar/gz/xz, PE/ELF/Mach-O, fonts, audio/video, SQLite, Parquet…). Container formats are refined: RIFF→WAV/AVI/WebP, ftyp→MP4/MOV/HEIC, ZIP→DOCX/XLSX/PPTX/JAR/APK/EPUB, OLE2→DOC/XLS/PPT/MSG/MSI. Text is classified as SVG/HTML/XML/JSON/PHP/shell/PEM/vCard… the way a browser would sniff it. |
| Extension consistency | Declared extension vs. detected type (with aliases jpg/jpeg, tif/tiff…); allow/deny rules like "image/*", "application/pdf", ".docx", "category:archive"; declared Content-Type vs. reality. |
| Filename safety | ../ traversal, absolute paths, null bytes, control characters, RTLO / bidi-override spoofing (invoice_gnp.exe), zero-width characters, Windows reserved names (CON, LPT1), .htaccess/web.config, dangerous and double extensions (shell.php.jpg). Plus sanitize_filename() that returns something safe to store. |
| SVG XSS | <script>, on* handlers, javascript:/data:text/html URIs (including obfuscated java\tscript:), <foreignObject>, HTML elements, external <use>/<image> references, CSS expression()/@import/url(), <!ENTITY> (XXE / billion laughs), <?xml-stylesheet?>. Either report or sanitise (returns clean bytes). |
| Archive bombs | ZIP: total uncompressed size, compression ratio, entry count, nested archives (recursively, bounded), overlapping-entry bombs, lying headers (optional real decompression with a cap), encrypted entries, symlinks, Zip Slip paths. gzip/bzip2/xz/tar: bounded streaming decompression that never buffers more than 64 KiB, tar header walk (GNU long names, PAX), Tar Slip, symlink escapes, device nodes. Also applies to DOCX/XLSX/PPTX/ODF (they are zips). |
| Polyglots | Secondary signatures inside images/documents (<?php in a GIF comment, ZIP after a GIF = GIFAR, PDF inside PNG, <script> in JPEG) and data appended after the format's EOF marker (PNG IEND, JPEG FFD9, GIF ;, PDF %%EOF) — the classic "image with a web-shell appended" trick. |
Everything runs with hard limits on bytes read, elements parsed and decompressed output, so the validator itself cannot be used as a DoS vector.
Install
pip install upload_guard
No compiled extensions, no libmagic, no third-party runtime dependencies. Python 3.9+ (the setuptools>=77 build backend needs 3.9; the wheel itself is pure Python).
Usage
One-shot functions
from upload_guard import check, scan, detect_type, sanitize_svg, sanitize_filename, inspect_archive
detect_type(b"\x89PNG\r\n\x1a\n...").mime # 'image/png'
detect_type("/tmp/upload.bin") # DetectedType(mime=..., extensions=(...), category=...)
result = scan(data, filename="cat.png") # never raises
result.ok, result.mime, result.findings
check(data, filename="cat.png", allowed=["image/*"]) # raises UploadRejected
clean = sanitize_svg(svg_bytes).sanitized # bytes without scripts/handlers/external refs
sanitize_filename("../../etc/passwd\x00.png") # 'passwd.png'
Policy
from upload_guard import UploadGuard, Policy, Severity, ArchiveLimits, SvgPolicy
guard = UploadGuard(
allowed=["image/*", "application/pdf", ".docx", ".xlsx"], # evaluated against the *detected* type
blocked=["category:executable", "category:script"], # default
max_size=20 * 1024 * 1024,
require_extension=True, # filename must have an extension
strict_extension=True, # ...and it must match the detected type
check_content_type=True, # declared Content-Type mismatch → LOW finding
allow_unknown=False, # unidentifiable binary → rejected
sanitize_svg=True, # clean SVGs instead of rejecting them
svg_policy=SvgPolicy(allow_external_references=False, allow_data_images=True),
inspect_archives=True,
archive_limits=ArchiveLimits(
max_total_uncompressed=512 * 1024 * 1024,
max_ratio=100.0,
max_entries=10_000,
max_nesting=1,
verify_sizes=False, # True: actually decompress ZIP entries (bounded) to catch lying headers
allow_symlinks=False,
),
polyglot_scan_limit=1024 * 1024,
check_trailing_data=True,
reject_threshold=Severity.MEDIUM, # findings at or above this severity reject the upload
)
Every UploadGuard keyword is a field of Policy; you can also pass a Policy object and override individual fields: UploadGuard(policy, max_size=5_000_000).
Reading the result
result = guard.scan(upload)
result.ok # bool
result.detected # DetectedType(mime, extensions, description, category)
result.mime # 'image/png'
result.extension # extension to store the file with (detected, else declared)
result.safe_filename # sanitised filename, extension added if missing
result.size
result.findings # list[Finding(code, severity, message, detail, remediated)]
result.errors # findings that caused rejection
result.warnings # everything else (including remediated SVG problems)
result.archive # ArchiveReport(entry_count, total_uncompressed, ratio, nested_archives, unsafe_paths…)
result.sanitized # cleaned SVG bytes when sanitising was enabled, else None
result.to_dict() # JSON-serialisable
Finding codes are stable strings, grouped by prefix: size.*, type.*, extension.*, content_type.*, filename.*, svg.*, archive.*, polyglot.*.
Severity semantics:
| Severity | Meaning | Examples |
|---|---|---|
CRITICAL |
Definitely hostile | svg.script, archive.bomb_size, filename.path_traversal, polyglot.embedded_php |
HIGH |
Violates policy or strongly suspicious | extension.mismatch, type.blocked, size.too_large, svg.foreign_object |
MEDIUM |
Suspicious, usually worth rejecting (default threshold) | polyglot.trailing_data, svg.external_reference, filename.double_extension (executable inner ext) |
LOW |
Informational / lying browsers | content_type.mismatch, filename.hidden, archive.encrypted |
INFO |
Notes | filename.double_extension (.tar.gz) |
Framework integration
Inputs are duck-typed, so plain bytes, io.BytesIO, paths, open files, Starlette/FastAPI UploadFile, Django UploadedFile and Werkzeug/Flask FileStorage all work directly. The stream position is always restored, so you can still save() / read() afterwards.
FastAPI
Full runnable app with tests: examples/fastapi_app. validate_upload raises
HTTPException 413 / 415 / 422 with a JSON body listing the findings; Guarded(...) is the same check as a
dependency.
# examples/fastapi_app/main.py
import os
from pathlib import Path
from fastapi import Depends, FastAPI, File, UploadFile
from upload_guard import UploadGuard
from upload_guard.integrations.fastapi import Guarded, validate_upload
# ---- 1. 設定一次:允許的類型以「偵測到的真實類型」為準 -------------------------------
guard = UploadGuard(
allowed=["image/*", "application/pdf", ".docx"],
max_size=5 * 1024 * 1024,
sanitize_svg=True, # SVG 會被消毒後接受,而不是整個拒絕
)
app = FastAPI()
def upload_dir() -> Path:
path = Path(os.environ.get("UPLOAD_DIR", "uploads"))
path.mkdir(parents=True, exist_ok=True)
return path
# ---- 2. 在 handler 內驗證:失敗時自動回 413 / 415 / 422 --------------------------------
@app.post("/upload")
async def upload(file: UploadFile = File(...)):
result = validate_upload(file, guard) # raises HTTPException on rejection
data = result.sanitized or await file.read() # 消毒後的 SVG,否則原始內容(位置已還原)
(upload_dir() / result.safe_filename).write_bytes(data)
return {
"mime": result.mime,
"filename": result.safe_filename,
"warnings": [f.code for f in result.warnings],
}
# ---- 3. 或者當成 dependency:欄位名由 field= 決定 -----------------------------------------
@app.post("/avatar")
async def avatar(result=Depends(Guarded(guard, field="avatar"))):
return {"mime": result.mime, "size": result.size}
Django
Full runnable project with tests: examples/django_app. UploadGuardValidator works on
forms.FileField and on model FileField / ImageField (it is @deconstructible, so migrations are fine);
validate_upload raises ValidationError with one error per finding.
# examples/django_app/views.py
from pathlib import Path
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from upload_guard import UploadGuard
from upload_guard.integrations.django import UploadGuardValidator, validate_upload
# ---- 1. 設定一次 --------------------------------------------------------------------
guard = UploadGuard(allowed=["image/*", "application/pdf"], max_size=5 * 1024 * 1024)
# ---- 2a. 表單 / Model 欄位:掛 validator,錯誤進 form.errors ------------------------------
class UploadForm(forms.Form):
file = forms.FileField(validators=[UploadGuardValidator(guard)])
@csrf_exempt # 範例用;真實專案請走 CSRF token 或 API key 驗證
@require_POST
def upload_form(request):
form = UploadForm(request.POST, request.FILES)
if not form.is_valid():
return JsonResponse({"errors": form.errors.get_json_data()}, status=400)
f = form.cleaned_data["file"]
result = guard.scan(f) # 取得 ScanResult(消毒結果、安全檔名)
_store(result, f)
return JsonResponse({"mime": result.mime, "filename": result.safe_filename})
# ---- 2b. 直接在 view 內驗證:失敗時拋 ValidationError -------------------------------------
@csrf_exempt
@require_POST
def upload_api(request):
f = request.FILES["file"]
try:
result = validate_upload(f, guard)
except ValidationError as exc:
return JsonResponse({"errors": [{"code": e.code, "message": e.message} for e in exc.error_list]}, status=400)
_store(result, f)
return JsonResponse({"mime": result.mime, "filename": result.safe_filename, "warnings": [w.code for w in result.warnings]})
def _store(result, uploaded):
root = Path(settings.MEDIA_ROOT)
root.mkdir(parents=True, exist_ok=True)
data = result.sanitized if result.sanitized is not None else uploaded.read() # 位置已還原
(root / result.safe_filename).write_bytes(data)
Flask
Full runnable app with tests: examples/flask_app. validate_upload aborts with the
matching Werkzeug HTTPException (413 / 415 / 422); use guard.scan() when you want to shape the response
yourself.
# examples/flask_app/app.py
import os
from pathlib import Path
from flask import Flask, jsonify, request
from upload_guard import UploadGuard
from upload_guard.integrations.flask import validate_upload
# ---- 1. 設定一次 --------------------------------------------------------------------
guard = UploadGuard(allowed=["image/*", "application/pdf"], max_size=5 * 1024 * 1024)
def create_app(upload_dir=None):
app = Flask(__name__)
app.config["UPLOAD_DIR"] = Path(upload_dir or os.environ.get("UPLOAD_DIR", "uploads"))
app.config["MAX_CONTENT_LENGTH"] = 6 * 1024 * 1024 # 第一道防線:Werkzeug 直接擋掉超大請求
# ---- 2. 驗證失敗時 validate_upload 會 abort(413/415/422),Flask 回對應錯誤頁 -------------
@app.post("/upload")
def upload():
f = request.files["file"]
result = validate_upload(f, guard)
app.config["UPLOAD_DIR"].mkdir(parents=True, exist_ok=True)
target = app.config["UPLOAD_DIR"] / result.safe_filename # 永遠用消毒後的檔名
if result.sanitized is not None:
target.write_bytes(result.sanitized) # 消毒後的 SVG
else:
f.save(target) # 串流位置已還原,可直接存
return jsonify(mime=result.mime, filename=result.safe_filename, warnings=[w.code for w in result.warnings])
# ---- 3. 想自己決定回應格式:用 scan() 取得完整結果 -------------------------------------------
@app.post("/check")
def check():
result = guard.scan(request.files["file"])
return jsonify(result.to_dict()), (200 if result.ok else 400)
return app
if __name__ == "__main__":
create_app().run(debug=True)
CLI
upload_guard scan photo.png report.pdf --allow "image/*" --allow application/pdf
upload_guard scan logo.svg --write-sanitized clean.svg
upload_guard scan suspicious.zip --json
upload_guard detect *.bin
Exit code 0 = all accepted, 1 = at least one rejected, 2 = I/O error.
Design notes
- Detected, not declared. Allow-lists and extension checks are evaluated against the sniffed type. A
.pngthat is really a PE file failsextension.mismatchandtype.blockedeven ifimage/*is allowed. - Browsers sniff too. Text starting with
<html,<script,<svgis classified as HTML/SVG regardless of extension, because that is what a browser will render — which is exactly how stored-XSS via "text" uploads happens. - Bounded everything. 2 KiB for sniffing, 1 MiB (configurable) for polyglot scanning, 64 KiB tail for trailing-data checks, streaming decompression with a hard output cap, element/depth caps for SVG parsing. The validator cannot be turned into the bomb.
- Remediation is tracked. When an SVG is sanitised, the findings stay in
result.findingswithremediated=Truefor logging, but they don't reject the upload. - Lossless for callers. Stream positions are restored; owned streams (paths, bytes) are closed; framework upload objects are left open.
Limitations
- Encrypted ZIP entries, 7z, RAR and Zstandard archives are identified but their contents are not inspected (they produce
archive.encrypted/ no report). - Detection is signature-based, not a parser: a valid-looking header followed by garbage is still "a PNG". Pair with an image decoder (Pillow
Image.verify()) when you need structural validation. - SVG sanitising uses an allow-leaning deny-list; if you need a strict allow-list of elements use
SvgPolicy.blocked_elementsto tighten further, or reject SVGs outright by excludingimage/svg+xmlfromallowed.
繁體中文簡介
upload_guard 是不依賴 libmagic 的純 Python 上傳檔案檢驗套件:
- Magic Number 鑑定:讀前 2048 bytes 判斷真實 MIME(含 docx/xlsx/pptx、doc/xls/ppt、HEIC、WebM 等容器格式細分)。
- 副檔名一致性:宣告副檔名/Content-Type 與偵測結果比對;
allowed=["image/*", ".pdf"]以「真實類型」為準。 - 檔名防護:
../、null byte、RTLO 字元偽裝、Windows 保留名、雙重副檔名;sanitize_filename()產生安全檔名。 - SVG 消毒:移除
<script>、on*、javascript:、<foreignObject>、外部參照、危險 CSS、XML 實體宣告。 - 壓縮炸彈 / Zip Slip:ZIP/TAR/gzip/bz2/xz 解壓大小與比率預估、巢狀壓縮、重疊項目、路徑穿越、symlink,全程有界串流,不落地。
- Polyglot:圖片中夾帶 PHP/ZIP/PDF/HTML 簽章、EOF 後尾隨資料。
- 框架友好:直接接受 FastAPI
UploadFile、DjangoUploadedFile、FlaskFileStorage、bytes、BytesIO、路徑。
License
MIT
Metadata
Release files for upload-guard 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| upload_guard-0.1.0.tar.gz | 89.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| upload_guard-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 154.0 kB
Release files / upload_guard-0.1.0.tar.gz
| Download URL | upload_guard-0.1.0.tar.gz |
|---|---|
| Size | 89.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
38458ba9e31df06732805e79dacaa4635dd1f901515c65b271cf16d0d4268fe6
|
|
BLAKE2b-256 checksum How to use checksums |
1459f79ecd8955075195fee603e0bf3feee873e9f55eb13cffc5e7107a9a54b6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logRelease files / upload_guard-0.1.0-py3-none-any.whl
| Download URL | upload_guard-0.1.0-py3-none-any.whl |
|---|---|
| Size | 64.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6e11a1be2f16ff5409fb4e346c439864818d7bfed18d0edcfa1e93d0cb2ccc1c
|
|
BLAKE2b-256 checksum How to use checksums |
34529d4d9c0ecbb0d57c962a2d6f2e6a652af1a0cc14dd82eaf9c0ccd93338fc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency log