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.
- 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.
- Make the file parameter optional (File(None)) if you want your own 400 for a missing file; a required File(...) answers 422.
- 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.
- 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.
- Write it under secrets.token_hex(16) plus the verified type's extension, in a folder outside every StaticFiles mount, opened with mode 'xb'.
- 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.
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
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
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
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
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 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.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"- 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.
- 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).
- 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/fastapi | No 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 . |
| CodeQL | py/path-injection | FastApi.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 |
| 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 | FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5) |
| fastapi | 0.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 |
| pytest | 9.1.1 |
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.
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-fastapi 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
- 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
- OWASP File Upload Cheat Sheet
- OWASP ASVS 5.0.0, V5 File Handling
- FastAPI: Request Files (UploadFile)
- FastAPI: Static Files
- Starlette: Responses (FileResponse)
- WHATWG MIME Sniffing: matching an image type pattern
- MDN: X-Content-Type-Options
Attack patterns: