Decide the type from the bytes and never let the client pick the name. Check UploadedFile.size against your limit, require the extension, file.content_type and the PNG or JPEG signature to agree, save under secrets.token_hex() outside MEDIA_ROOT and every static folder, and serve with FileResponse(as_attachment=True) plus X-Content-Type-Options: nosniff.
- Find every view that reads request.FILES and where the file goes: open() or default_storage.save() with file.name, a folder under MEDIA_ROOT that MEDIA_URL serves, or a check that only looks at file.content_type.
- Enforce the feature's size limit with UploadedFile.size (and read at most limit + 1 bytes), and put a request-size limit in the web server or proxy, because Django limits only non-file form data (DATA_UPLOAD_MAX_MEMORY_SIZE) and the number of files (DATA_UPLOAD_MAX_NUMBER_FILES).
- Accept the file only if the lower-cased extension is on your allowlist, file.content_type is the type that extension names, and the content starts with that type's signature. Answer 415 otherwise.
- Write it under secrets.token_hex(16) plus the verified type's extension, outside every folder a URL serves, opened with mode 'xb'.
- Serve it from a view that accepts only names your code generates and returns FileResponse(open(path, 'rb'), as_attachment=True, filename=name, content_type=...) with X-Content-Type-Options: nosniff, or serve user files from a separate origin.
- Add regression tests with this card's payloads, sending the raw multipart body so the file name and Content-Type reach the server as an attacker writes them.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def upload_avatar(request):
file = request.FILES.get("avatar")
if file is None or not file.name:
return JsonResponse({"error": 'attach the image in the "avatar" field'}, status=400)
uploads = os.path.join(settings.AVATAR_WEB_ROOT, "uploads")
with open(os.path.join(uploads, file.name), "wb") as stored:
for chunk in file.chunks():
stored.write(chunk)
# The web root is served as static files, so the file is public as soon as it is saved.
return JsonResponse({"url": "/uploads/" + quote(file.name, safe="")}, status=201)With IMPL=vulnerable, 7 of 13 tests fail (7 exploit tests).
The fix: check and store the upload
MAX_BYTES = 512 * 1024
class ImageType(NamedTuple):
mimetype: str
extension: str
signature: bytes
PNG = ImageType("image/png", ".png", b"\x89PNG\r\n\x1a\n")
JPEG = ImageType("image/jpeg", ".jpg", b"\xff\xd8\xff")
# The only types this feature needs, keyed by the extensions that may name them.
TYPES = {".png": PNG, ".jpg": JPEG, ".jpeg": JPEG}
def upload_avatar(request):
file = request.FILES.get("avatar")
if file is None or not file.name:
return JsonResponse({"error": 'attach the image in the "avatar" field'}, status=400)
# Django has no per-file size limit of its own; UploadedFile.size is the parsed size.
if file.size > MAX_BYTES:
raise UploadError(413, "avatars must be at most 512 KiB")
content = file.read(MAX_BYTES + 1)
# splitext() returns the last extension only: ".html" for avatar.png.html.
image_type = TYPES.get(os.path.splitext(file.name)[1].lower())
if (
image_type is None
or file.content_type != image_type.mimetype
or not content.startswith(image_type.signature)
):
raise UploadError(415, "avatars must be PNG or JPEG images")
# A name the server makes up, in a folder no URL serves; "xb" never overwrites.
name = secrets.token_hex(16) + image_type.extension
with open(os.path.join(settings.AVATAR_STORAGE_DIR, name), "xb") as stored:
stored.write(content)
return JsonResponse({"url": f"/avatars/{name}"}, status=201)With IMPL=fixed, 13 of 13 tests pass.
The fix: serve it as a download
STORED_NAME = re.compile(r"[0-9a-f]{32}\.(png|jpg)")
def serve_avatar(request, name: str):
# Only names this code generates; anything else, ../ included, is simply not found.
if not STORED_NAME.fullmatch(name):
raise Http404
mimetype = PNG.mimetype if name.endswith(".png") else JPEG.mimetype
# as_attachment sends Content-Disposition: attachment; nosniff tells the browser not to
# second-guess the Content-Type (SecurityMiddleware also adds it by default).
response = FileResponse(
open(os.path.join(settings.AVATAR_STORAGE_DIR, name), "rb"),
as_attachment=True,
filename=name,
content_type=mimetype,
)
response["X-Content-Type-Options"] = "nosniff"
return responseWith IMPL=fixed, 13 of 13 tests pass.
A fix that looks safe but is not
ALLOWED_TYPES = {"image/png", "image/jpeg"}
MAX_BYTES = 512 * 1024
def upload_avatar(request):
file = request.FILES.get("avatar")
if file is None or not file.name:
return JsonResponse({"error": 'attach the image in the "avatar" field'}, status=400)
if file.content_type not in ALLOWED_TYPES:
raise UploadError(415, "avatars must be PNG or JPEG images")
if file.size > MAX_BYTES:
raise UploadError(413, "avatars must be at most 512 KiB")
name = uuid.uuid4().hex + os.path.splitext(file.name)[1].lower()
with open(os.path.join(settings.AVATAR_WEB_ROOT, "uploads", name), "wb") as stored:
for chunk in file.chunks():
stored.write(chunk)
return JsonResponse({"url": f"/uploads/{name}"}, status=201)With IMPL=wrong, 3 of 13 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: what an attacker sends instead of a picture.
def test_exploit_html_page_with_a_script_is_refused(client, tree, seed, images):
res = upload(client, "profile.html", "text/html", images["html"])
assert_refused(client, tree, seed, res, 415)
def test_exploit_svg_with_a_script_is_refused(client, tree, seed, images):
res = upload(client, "avatar.svg", "image/svg+xml", images["svg"])
assert_refused(client, tree, seed, res, 415)
def test_exploit_html_named_png_and_declared_image_png_is_refused(client, tree, seed, images):
res = upload(client, "avatar.png", "image/png", images["html"])
assert_refused(client, tree, seed, res, 415)
def test_exploit_double_extension_is_refused_even_when_the_bytes_start_like_a_png(client, tree, seed, images):
res = upload(client, "avatar.png.html", "image/png", images["polyglot"])
assert_refused(client, tree, seed, res, 415)
def test_exploit_png_that_is_also_an_html_page_is_served_only_as_an_image_download(client, images):
res = upload(client, "avatar.png", "image/png", images["polyglot"])
assert res.status == 201
served = client.get(res.url)
assert mimetype(served) == "image/png"
assert served.get("X-Content-Type-Options") == "nosniff"
assert served.get("Content-Disposition", "inline").startswith("attachment")
def test_exploit_dot_dot_slash_in_the_file_name_cannot_put_the_file_outside_the_upload_folder(client, tree, seed, images):
upload(client, "../escaped.png", "image/png", images["png"])
assert files_outside_upload_folders(tree, seed) == []
def test_exploit_absolute_file_name_cannot_put_the_file_outside_the_upload_folder(client, tree, seed, images):
upload(client, str(tree / "escaped.png"), "image/png", images["png"])
assert files_outside_upload_folders(tree, seed) == []
def test_exploit_file_one_byte_over_the_limit_is_refused_with_413(client, tree, seed, images):
res = upload(client, "avatar.png", "image/png", pad_to(images["png"], seed["limitBytes"] + 1))
assert_refused(client, tree, seed, res, 413)
def test_exploit_upload_cannot_replace_another_users_file_by_reusing_its_name(client, images):
alice = upload(client, "avatar.png", "image/png", images["png"])
mallory = upload(client, "avatar.png", "image/png", images["other_png"])
assert alice.status == 201
assert mallory.status == 201
assert body(client.get(alice.url)) == images["png"], "Alice's URL must still serve her own image"- Django's MultiPartParser.sanitize_file_name strips everything up to the last / or \ and drops . and .., so ../escaped.png arrives as escaped.png and the two path tests pass in every mode here; Django's own docs still call the result untrusted, and a repeated name still overwrites another user's file.
- UploadedFile.content_type is the Content-Type of the multipart part, which the client writes; an HTML page declared image/png passes a check on it.
- Serving uploads from MEDIA_ROOT (or with django.views.static.serve, which Django says is not for production) sends them inline with a Content-Type guessed from the extension, so <random>.html is served as text/html. SecurityMiddleware's default nosniff does not change that: the browser is told the file is HTML.
- FILE_UPLOAD_MAX_MEMORY_SIZE (2.5 MB by default) only decides when an upload is streamed to a temporary file instead of memory; it is not a size limit. DATA_UPLOAD_MAX_MEMORY_SIZE excludes file data.
- Django's test client strips the path from a file's name before sending it, so a regression test for hostile file names must build the multipart body itself, as this card's tests do.
- A file signature proves how a file starts, not that it is harmless: the polyglot test is a valid PNG that is also a working page. The download headers keep it from rendering; re-encode images (Pillow) if you also need to strip trailing data.
- Behind session authentication, the upload is a cross-site form post like any other; keep CsrfViewMiddleware in place.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs file-upload/python-django does this for you).
- Against your own endpoint, upload an HTML file named avatar.png with Content-Type image/png and a .svg with a <script> element; both must be refused with nothing stored. Open an accepted avatar's URL and check for Content-Disposition: attachment and X-Content-Type-Options: nosniff.
- No scanner checked here notices a missing type check or the serving headers; the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/python, p/owasp-top-ten, p/security-audit, p/django | No findings on any implementation. Also run: r/python.django.security.injection.path-traversal.path-traversal-file-name.path-traversal-file-name and r/python.django.security.injection.request-data-fileresponse.request-data-fileresponse, with no findings either: neither matches request.FILES[...].name joined into an open() path, and Django strips the path from the name anyway.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/django --exclude tests . |
| CodeQL | py/path-injection | Django.qll makes request.FILES a remote source, so the vulnerable open(os.path.join(uploads, file.name)) may be reported although Django has already stripped the path. Nothing models the missing type check or the serving headers.Checked: github/codeql main at b5b8165; not executed |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings in any implementation. Bandit has no plugin for uploaded file names, types or sizes, so its silence says nothing about this weakness.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 (request.FILES, FileResponse, django.views.static.serve) |
| django | 6.1.2 |
| secrets and os.path (standard library) | Python 3.14.8 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the file-upload/python-djangofolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs file-upload/python-django runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- django 6.1.2
- pytest 9.1.1
- pytest-django 4.14.0
- Matrix
- fixed: 13 of 13 tests pass; vulnerable: 7 of 13 tests fail (7 exploit tests); wrong: 3 of 13 tests fail (3 exploit tests)
- Test results
- IMPL=fixed 13/13 pass; IMPL=vulnerable 7/13 fail, all exploit (HTML, SVG, HTML named avatar.png, avatar.png.html, the polyglot's headers, one byte over the limit, the reused name); IMPL=wrong 3/13 fail, all exploit (HTML named avatar.png, avatar.png.html served as text/html, the polyglot's headers). The ../ and absolute-name tests pass in every mode because Django's parser strips the path (MultiPartParser.sanitize_file_name, read in Django 6.1.2's source), as multer does in the Node card. Same test names as the other stacks; tests/seed.json is byte-identical.
- Dependency freshness
- django 6.1.2 (2026-10-06, a security release), pytest-django 4.14.0 (2026-08-10) and pytest 9.1.1 (2026-06-19) match the other Django cards. Python 3.14.8 is the 30 September 2026 security release.
- Scanners
- Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); CodeQL read from github/codeql main at b5b8165, not executed.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP File Upload Cheat Sheet
- Django 6.1: File Uploads (upload handlers)
- Django 6.1: Security, user-uploaded content
- Django 6.1 settings: FILE_UPLOAD_MAX_MEMORY_SIZE, DATA_UPLOAD_MAX_MEMORY_SIZE, DATA_UPLOAD_MAX_NUMBER_FILES
- Django 6.1: FileResponse
- OWASP ASVS 5.0.0, V5 File Handling
- MDN: X-Content-Type-Options
Attack patterns: