Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix unrestricted file upload in Python and Django

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Django 6.1.2 (request.FILES, FileResponse, django.views.static.serve)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. 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.
  2. 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).
  3. 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.
  4. Write it under secrets.token_hex(16) plus the verified type's extension, outside every folder a URL serves, opened with mode 'xb'.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerableavatars/impl/vulnerable.pyPython
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

Fixedavatars/impl/fixed.pyPython
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

Fixedavatars/impl/fixed.pyPython
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 response

With IMPL=fixed, 13 of 13 tests pass.

A fix that looks safe but is not

Wrong fixavatars/impl/wrong.pyPython
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 regression test

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.

Testtests/test_avatars.pyPython
# 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"

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. No scanner checked here notices a missing type check or the serving headers; the regression tests are the control.

Scanner rules

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.

Scanner rules for Fix unrestricted file upload in Python and Django
ToolRuleWhat it found here
Semgrepp/python, p/owasp-top-ten, p/security-audit, p/djangoNo 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 .
CodeQLpy/path-injectionDjango.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
Banditall plugins (default profile)Bandit 1.9.4No 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkDjango 6.1.2 (request.FILES, FileResponse, django.views.static.serve)
django6.1.2
secrets and os.path (standard library)Python 3.14.8
pytest9.1.1
pytest-django4.14.0

Run the tests

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.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 24 KB

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

  1. OWASP File Upload Cheat Sheet
  2. Django 6.1: File Uploads (upload handlers)
  3. Django 6.1: Security, user-uploaded content
  4. Django 6.1 settings: FILE_UPLOAD_MAX_MEMORY_SIZE, DATA_UPLOAD_MAX_MEMORY_SIZE, DATA_UPLOAD_MAX_NUMBER_FILES
  5. Django 6.1: FileResponse
  6. OWASP ASVS 5.0.0, V5 File Handling
  7. MDN: X-Content-Type-Options

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.