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

Fix unrestricted file upload in Python and Flask

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. Cap request.max_content_length, check the file's exact size, require the extension, file.mimetype and the PNG or JPEG signature to agree, save under secrets.token_hex() outside the static folder, and serve with send_from_directory(as_attachment=True) plus X-Content-Type-Options: nosniff.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Flask 3.1.3
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 27 KB
On this page

Steps

  1. Find every view that reads request.files and where the file goes: FileStorage.save() or open() with file.filename in the path, a folder under app.static_folder, or a check that only looks at file.mimetype or file.content_type.
  2. Set request.max_content_length (Flask 3.1 and later; MAX_CONTENT_LENGTH for the whole app) a little above the file limit so Werkzeug stops oversized bodies with 413, then read at most limit + 1 bytes from the file and answer 413 if you got more.
  3. Accept the file only if os.path.splitext(file.filename)[1].lower() is on your allowlist, file.mimetype is the type that extension names, and the content starts with that type's signature (b'\x89PNG\r\n\x1a\n' for PNG, b'\xff\xd8\xff' for JPEG). Answer 415 otherwise.
  4. Write it under secrets.token_hex(16) plus the extension of the verified type, in a folder outside the static folder, opened with mode 'xb' so an existing file is never replaced.
  5. Serve it from a view that accepts only names your code generates and returns send_from_directory(folder, name, mimetype=..., as_attachment=True) with X-Content-Type-Options: nosniff added, or serve uploads from a separate origin.
  6. Add regression tests with this card's payloads: an HTML page and an SVG with script, HTML named avatar.png, avatar.png.html, a PNG that is also a page, ../ and absolute file names, a file one byte over the limit, and two users uploading the same name.

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

Vulnerableimpl/vulnerable.pyPython
@bp.post("/avatars")
def upload_avatar():
    file = request.files.get("avatar")
    if file is None or not file.filename:
        return {"error": 'attach the image in the "avatar" field'}, 400
    uploads = os.path.join(current_app.config["WEB_ROOT"], "uploads")
    file.save(os.path.join(uploads, file.filename))
    # Flask serves the web root as static files, so the file is public as soon as it is saved.
    return {"url": "/uploads/" + quote(file.filename, safe="")}, 201

With IMPL=vulnerable, 9 of 13 tests fail (9 exploit tests).

The fix: check and store the upload

Fixedimpl/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}


@bp.post("/avatars")
def upload_avatar():
    # Werkzeug refuses a larger body with RequestEntityTooLarge (413) instead of reading it. The
    # allowance covers the multipart boundary and part headers; the file's exact limit is checked below.
    request.max_content_length = MAX_BYTES + 16 * 1024
    file = request.files.get("avatar")
    if file is None or not file.filename:
        return {"error": 'attach the image in the "avatar" field'}, 400
    content = file.read(MAX_BYTES + 1)
    if len(content) > MAX_BYTES:
        raise UploadError(413, "avatars must be at most 512 KiB")
    # splitext() returns the last extension only: ".html" for avatar.png.html.
    image_type = TYPES.get(os.path.splitext(file.filename)[1].lower())
    if (
        image_type is None
        or file.mimetype != 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 Flask does not serve; "xb" never overwrites.
    name = secrets.token_hex(16) + image_type.extension
    with open(os.path.join(current_app.config["STORAGE_DIR"], name), "xb") as stored:
        stored.write(content)
    return {"url": f"/avatars/{name}"}, 201

With IMPL=fixed, 13 of 13 tests pass.

The fix: serve it as a download

Fixedimpl/fixed.pyPython
STORED_NAME = re.compile(r"[0-9a-f]{32}\.(png|jpg)")


@bp.get("/avatars/<name>")
def serve_avatar(name: str):
    # Only names this code generates; anything else, ../ included, is simply not found.
    if not STORED_NAME.fullmatch(name):
        abort(404)
    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.
    response = send_from_directory(
        current_app.config["STORAGE_DIR"], name, mimetype=mimetype, as_attachment=True
    )
    response.headers["X-Content-Type-Options"] = "nosniff"
    return response

With IMPL=fixed, 13 of 13 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
ALLOWED_TYPES = {"image/png", "image/jpeg"}
MAX_BYTES = 512 * 1024


@bp.post("/avatars")
def upload_avatar():
    file = request.files.get("avatar")
    if file is None or not file.filename:
        return {"error": 'attach the image in the "avatar" field'}, 400
    if file.mimetype not in ALLOWED_TYPES:
        raise UploadError(415, "avatars must be PNG or JPEG images")
    content = file.read(MAX_BYTES + 1)
    if len(content) > MAX_BYTES:
        raise UploadError(413, "avatars must be at most 512 KiB")
    name = uuid.uuid4().hex + os.path.splitext(file.filename)[1].lower()
    with open(os.path.join(current_app.config["WEB_ROOT"], "uploads", name), "wb") as stored:
        stored.write(content)
    return {"url": f"/uploads/{name}"}, 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 served.mimetype == "image/png"
    assert served.headers.get("X-Content-Type-Options") == "nosniff"
    assert served.headers.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 client.get(alice.url).data == images["png"], "Alice's URL must still serve her own image"

Pitfalls

  • Werkzeug passes the client's file name through unchanged: in this card's tests FileStorage.filename was ../escaped.png and an absolute path, and the vulnerable save() wrote outside the upload folder. os.path.join() drops the folder entirely when the second part is absolute.
  • secure_filename() turns ../escaped.png into escaped.png, but it keeps avatar.png.html as it is and keeps names that collide, so it fixes the path, not the type or the overwrite. Generate the stored name instead.
  • file.mimetype is the Content-Type of the multipart part, lower-cased and without parameters; the client writes it. A script can send image/png with an HTML body, which is how avatar.png.html gets past the wrong implementation.
  • Flask's static route serves files with Content-Disposition: inline, a Content-Type guessed from the extension and no X-Content-Type-Options, so <random>.html in the static folder is served as text/html.
  • MAX_CONTENT_LENGTH and request.max_content_length count the whole request body, multipart boundaries and part headers included, and default to None (no limit). Set them a little above the file limit and check the file's own length exactly; otherwise a file of exactly the limit is refused.
  • MAX_FORM_MEMORY_SIZE (500,000 bytes by default since Flask 3.1) limits non-file fields only. Werkzeug spools large files to a temporary file, so without a content-length limit a client decides how much disk an upload uses.
  • 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 serving headers are what keep it from rendering; re-encode images with a maintained library such as Pillow if you also need to strip trailing data and metadata.
  • Content-Disposition: attachment does not stop an <img> element from showing the avatar; it makes a direct visit to the URL download the file instead of rendering it.
  • The upload view here has no sign-in. Behind cookie authentication, a multipart form post is a cross-site request like any other, so it needs CSRF protection (Flask-WTF's CSRFProtect, see the CSRF card).

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-flask 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 the URL of an accepted avatar and check for Content-Disposition: attachment and X-Content-Type-Options: nosniff.
  3. If you use CodeQL, py/path-injection's models cover FileStorage.save() with the client's file name (not executed for this card), but 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 Flask
ToolRuleWhat it found here
Semgrepp/default, p/security-audit, p/owasp-top-tenOne finding, unrelated to uploads: python.lang.security.audit.non-literal-import.non-literal-import on the IMPL loader in app.py. Nothing in impl/: the semgrep-rules repository (develop at 9bbf021) has no rule for request.files, FileStorage.save() or FileStorage.mimetype.Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/default --config p/security-audit --config p/owasp-top-ten app.py impl upload_error.py
Semgreppython.flask.security.injection.path-traversal-open.path-traversal-openNo findings. The rule only matches open() calls whose argument contains flask.request data, or a route argument in an @app.route view; the vulnerable code writes with FileStorage.save(), and the wrong code's open() receives a name built from the file name, not the request object itself.Checked: Semgrep 1.180.0 (CE); semgrep --config r/python.flask.security.injection.path-traversal-open.path-traversal-open impl (Ran 1 rule on 4 files)
CodeQLpy/path-injectionFrom Flask.qll and Werkzeug.qll: request.files.get() returns a modelled FileStorage, its filename attribute carries taint, and FileStorage.save() is a file-system access whose first argument is a path sink, so the vulnerable file.save(os.path.join(uploads, file.filename)) should be reported. os.path.splitext() also carries taint, so the wrong code's open() would probably be reported too, although its extension cannot contain a separator. Nothing models the type checks the wrong code is missing.Checked: github/codeql main at b5b8165; not executed
Banditn/aNo Bandit test covers uploadsNo issues in the six files scanned; Bandit has no test for uploaded file names, types or sizes.Checked: Bandit 1.9.4; bandit -r impl app.py upload_error.py

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
werkzeug (multipart parsing, FileStorage, send_file)3.1.9
secrets and os.path (standard library)Python 3.14.8
pytest9.1.1

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

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-flask runs all three modes and checks the expected results.

Download the project ZIP, 27 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • flask 3.1.3
  • werkzeug 3.1.9
  • pytest 9.1.1
Matrix
fixed: 13 of 13 tests pass; vulnerable: 9 of 13 tests fail (9 exploit tests); wrong: 3 of 13 tests fail (3 exploit tests)
Test results
IMPL=fixed 13/13 pass; IMPL=vulnerable 9/13 fail, all exploit tests (HTML, SVG, HTML named avatar.png, avatar.png.html, the polyglot's headers, ../ and the absolute name both writing outside the upload folder, one byte over the limit, the reused name); IMPL=wrong 3/13 fail, all exploit tests (HTML named avatar.png, avatar.png.html served as text/html, the polyglot's headers). A separate probe confirmed that the fixed view's request.max_content_length answers a 2 MiB upload with 413 before the view reads the file. The same results came from a copy installed with empty package caches and run in a no-network sandbox.
Dependency freshness
requirements.txt is the same hash-pinned set as the path-traversal card: all 12 packages with sha256 hashes for every file PyPI lists, so pip installs in hash-checking mode. werkzeug 3.1.9 (2026-09-27) is 13 days old and taken because it is a security release (GHSA-g6x2-hccm-hh4m). flask 3.1.3 (2026-02-19) and pytest 9.1.1 (2026-06-19) are the latest releases.
Scanners
Semgrep 1.180.0 and Bandit 1.9.4 from a throwaway venv (pip install semgrep==1.180.0 bandit==1.9.4), not card dependencies; CodeQL read from source, 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. OWASP ASVS 5.0.0, V5 File Handling
  3. Flask: Uploading Files
  4. Flask: Request.max_content_length (per request since 3.1)
  5. Flask: send_from_directory()
  6. Werkzeug: FileStorage
  7. Werkzeug: secure_filename()
  8. WHATWG MIME Sniffing: matching an image type pattern
  9. 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.