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

Fix unrestricted file upload in Python and FastAPI

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. Read at most limit + 1 bytes from the UploadFile, require the extension, avatar.content_type and the PNG or JPEG signature to agree, save under secrets.token_hex() outside the StaticFiles directory, and serve with FileResponse(filename=...) plus X-Content-Type-Options: nosniff.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 22 KB
On this page

Steps

  1. Find every path operation that takes an UploadFile and where the file goes: open() with UploadFile.filename in the path, a folder mounted with StaticFiles, or a check that only looks at UploadFile.content_type.
  2. Make the file parameter optional (File(None)) if you want your own 400 for a missing file; a required File(...) answers 422.
  3. Read at most limit + 1 bytes with await avatar.read(MAX_BYTES + 1) and answer 413 if you got more; also set a body-size limit in the ASGI server or proxy, because Starlette has none.
  4. Accept the file only if the lower-cased extension is on your allowlist, avatar.content_type is the type that extension names, and the content starts with that type's signature (b'\x89PNG\r\n\x1a\n' or b'\xff\xd8\xff'). Answer 415 otherwise.
  5. Write it under secrets.token_hex(16) plus the verified type's extension, in a folder outside every StaticFiles mount, opened with mode 'xb'.
  6. Serve it from a route that accepts only names your code generates and returns FileResponse(path, media_type=..., filename=name, headers={'X-Content-Type-Options': 'nosniff'}), which sends Content-Disposition: attachment.

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
async def upload_avatar(request: Request, avatar: UploadFile | None = File(None)):
    if avatar is None or not avatar.filename:
        return JSONResponse({"error": 'attach the image in the "avatar" field'}, status_code=400)
    uploads = os.path.join(request.app.state.web_root, "uploads")
    with open(os.path.join(uploads, avatar.filename), "wb") as stored:
        stored.write(await avatar.read())
    # StaticFiles serves the web root, so the file is public as soon as it is saved.
    return JSONResponse({"url": "/uploads/" + quote(avatar.filename, safe="")}, status_code=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}


async def upload_avatar(request: Request, avatar: UploadFile | None = File(None)):
    if avatar is None or not avatar.filename:
        return JSONResponse({"error": 'attach the image in the "avatar" field'}, status_code=400)
    # Starlette has no request-size limit of its own; read at most one byte past the limit.
    content = await avatar.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(avatar.filename)[1].lower())
    if (
        image_type is None
        or avatar.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 StaticFiles does not serve; "xb" never overwrites.
    name = secrets.token_hex(16) + image_type.extension
    with open(os.path.join(request.app.state.storage_dir, name), "xb") as stored:
        stored.write(content)
    return JSONResponse({"url": f"/avatars/{name}"}, status_code=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)")


async def serve_avatar(request: Request, name: str):
    # Only names this code generates; anything else, ../ included, is simply not found.
    if not STORED_NAME.fullmatch(name):
        raise HTTPException(status_code=404)
    mimetype = PNG.mimetype if name.endswith(".png") else JPEG.mimetype
    # filename= makes FileResponse send Content-Disposition: attachment; nosniff tells the
    # browser not to second-guess the Content-Type.
    return FileResponse(
        os.path.join(request.app.state.storage_dir, name),
        media_type=mimetype,
        filename=name,
        headers={"X-Content-Type-Options": "nosniff"},
    )

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


async def upload_avatar(request: Request, avatar: UploadFile | None = File(None)):
    if avatar is None or not avatar.filename:
        return JSONResponse({"error": 'attach the image in the "avatar" field'}, status_code=400)
    if avatar.content_type not in ALLOWED_TYPES:
        raise UploadError(415, "avatars must be PNG or JPEG images")
    content = await avatar.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(avatar.filename)[1].lower()
    with open(os.path.join(request.app.state.web_root, "uploads", name), "wb") as stored:
        stored.write(content)
    return JSONResponse({"url": f"/uploads/{name}"}, status_code=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.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).content == images["png"], "Alice's URL must still serve her own image"

Pitfalls

  • UploadFile.filename is the client's file name unchanged: in this card's tests python-multipart 0.0.32 passed ../escaped.png and an absolute path through, and the vulnerable open() wrote outside the upload folder.
  • UploadFile.content_type is the Content-Type of the multipart part, which the client writes; a script can send image/png with an HTML body.
  • StaticFiles serves files inline with a Content-Type guessed from the extension and no X-Content-Type-Options, so <random>.html under a mounted directory is served as text/html.
  • Starlette does not limit the size of an uploaded file. Its form parser (1.7.0) caps non-file fields at 1 MB (max_part_size) and allows 1,000 files and 1,000 fields, but each file part is spooled to a temporary file (in memory up to 1 MB, then on disk) with no size limit before your code runs, so cap the body in the server or proxy as well as checking the file's length.
  • Mount StaticFiles after the API routes when it is mounted at /, or the mount catches the requests first.
  • 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 keep it from rendering; re-encode images with a maintained library if you also need to strip trailing data.
  • Content-Disposition: attachment does not stop an <img> element from showing the avatar; it makes a direct visit download the file instead of rendering it.

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-fastapi 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 FastAPI
ToolRuleWhat it found here
Semgrepp/python, p/owasp-top-ten, p/security-audit, p/fastapiNo findings on any implementation. semgrep-rules develop at 9bbf021 has no rule for UploadFile, UploadFile.filename or UploadFile.content_type, or for the serving headers.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/fastapi --exclude tests .
CodeQLpy/path-injectionFastApi.qll treats path-operation parameters as remote sources; whether UploadFile.filename carries taint into the vulnerable open(os.path.join(uploads, avatar.filename)) depends on Starlette's UploadFile model, which was not confirmed. 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
FrameworkFastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
fastapi0.142.2
starlette (UploadFile, StaticFiles, FileResponse)1.7.0
python-multipart (multipart parsing)0.0.32
secrets and os.path (standard library)Python 3.14.8
httpx2 (TestClient transport, test only)2.13.1
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-fastapifolder.

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

Download the project ZIP, 22 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • fastapi 0.142.2
  • starlette 1.7.0
  • pydantic 2.13.5
  • python-multipart 0.0.32
  • httpx2 2.13.1
  • 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 (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 (HTML named avatar.png, avatar.png.html served as text/html, the polyglot's headers). The same counts and test names as the Flask card; tests/seed.json is byte-identical to the other stacks' copies.
Dependency freshness
fastapi 0.142.2 (2026-09-30), starlette 1.7.0 (2026-09-23), pydantic 2.13.5 (2026-08-28) match the other FastAPI cards; python-multipart 0.0.32 (2026-06-04) is the latest. 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. OWASP ASVS 5.0.0, V5 File Handling
  3. FastAPI: Request Files (UploadFile)
  4. FastAPI: Static Files
  5. Starlette: Responses (FileResponse)
  6. WHATWG MIME Sniffing: matching an image type pattern
  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.