Web Security (OWASP Top 10, fix-side) · lesson 5 of 6
Beginner
3Intermediate
3Also part of Web Security (OWASP Top 10, fix-side)
12- + Python Security from Python
- + Database Security from SQL
- + JavaScript Security Best Practices from JavaScript
- + Java Security from Java
- + Bash Security Best Practices from Bash
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + FastAPI Roles, CORS and Settings from Environment Variables from FastAPI
- + Spring Security — Authentication and Authorization from Java
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + C Secure Coding: Buffer Overflows, Sanitizers, and Safe Functions from C
- + LLM Security Beyond Guardrails from LLM Engineering
- + Dependency Scanning (SCA) and SBOMs with pip-audit, Syft and Grype from DevSecOps (AppSec in the pipeline)
Also part of: Ethical Hacking (methodology, lab-only)
Unsafe File Uploads: secure_filename, Allowlists and Size Limits
A Flask avatar upload writes outside its folder and accepts any file. Fix it with secure_filename, an extension allowlist and MAX_CONTENT_LENGTH.
By SkillByExample · Published
An upload form hands the server a filename and some bytes, and the client controls both. This
lesson builds a Flask avatar upload that trusts them, proves three problems with pytest, and fixes
them with secure_filename, an extension allowlist and a size limit. Everything runs through
Flask’s test client in a temporary folder, with no server and no network. To learn the attack
side, see PortSwigger’s file upload
labs.
The lab uses the same environment as the first lesson: Python 3.12, Flask 3.1.3 and pytest 9.1.1.
An upload that trusts the filename
The route joins the client’s filename to the upload folder and saves the file there.
# app.py
import os
from flask import Flask, request
app = Flask(__name__)
app.config["UPLOAD_DIR"] = "uploads"
@app.post("/avatar")
def upload_avatar():
f = request.files["file"]
path = os.path.join(app.config["UPLOAD_DIR"], f.filename)
f.save(path)
return {"saved": f.filename}, 201
The tests need a fresh upload folder for each run and a short way to post a file. pytest’s
tmp_path gives each test its own empty directory, and io.BytesIO stands in for a file on disk:
# conftest.py
import io
import pytest
from app import app
@pytest.fixture
def client(tmp_path):
(tmp_path / "uploads").mkdir()
app.config["UPLOAD_DIR"] = tmp_path / "uploads"
return app.test_client()
def upload(client, name, data=b"\x89PNG\r\n\x1a\n"):
return client.post("/avatar", data={"file": (io.BytesIO(data), name)})
The default data is the 8-byte signature every PNG file starts with.
Four tests, three bugs
The rules: a PNG is saved, a filename can’t escape the upload folder, an HTML file is refused, and a 2 MB upload is refused.
# test_upload.py
from conftest import upload
def test_png_is_saved(client, tmp_path):
assert upload(client, "me.png").status_code == 201
assert (tmp_path / "uploads" / "me.png").exists()
def test_filename_cannot_leave_upload_dir(client, tmp_path):
upload(client, "../me.png")
escaped = (tmp_path / "me.png").exists()
assert not escaped
def test_html_is_rejected(client):
assert upload(client, "page.html").status_code == 400
def test_large_file_is_rejected(client):
assert upload(client, "big.png", b"x" * 2_000_000).status_code == 413
$ pytest -q --tb=short
.FFF [100%]
================================== FAILURES ===================================
____________________ test_filename_cannot_leave_upload_dir ____________________
test_upload.py:13: in test_filename_cannot_leave_upload_dir
assert not escaped
E assert not True
____________________________ test_html_is_rejected ____________________________
test_upload.py:17: in test_html_is_rejected
assert upload(client, "page.html").status_code == 400
E AssertionError: assert 201 == 400
E + where 201 = <WrapperTestResponse streamed [201 CREATED]>.status_code
E + where <WrapperTestResponse streamed [201 CREATED]> = upload(<FlaskClient <Flask 'app'>>, 'page.html')
_________________________ test_large_file_is_rejected _________________________
test_upload.py:21: in test_large_file_is_rejected
assert upload(client, "big.png", b"x" * 2_000_000).status_code == 413
E AssertionError: assert 201 == 413
E + where 201 = <WrapperTestResponse streamed [201 CREATED]>.status_code
E + where <WrapperTestResponse streamed [201 CREATED]> = upload(<FlaskClient <Flask 'app'>>, 'big.png', (b'x' * 2000000))
=========================== short test summary info ===========================
FAILED test_upload.py::test_filename_cannot_leave_upload_dir - assert not True
FAILED test_upload.py::test_html_is_rejected - AssertionError: assert 201 == 400
FAILED test_upload.py::test_large_file_is_rejected - AssertionError: assert 2...
3 failed, 1 passed in 0.14s
assert not True is the path traversal: a file named ../me.png was written one level above
uploads. Flask doesn’t clean f.filename; it is whatever the client put in the request. Here it
only landed in the test’s temporary folder, but on a server the same ../ steps out of the upload
folder into the application’s own files. The other two failures are missing rules: an HTML page
was stored as happily as an image, and nothing capped the size.
What secure_filename does
Werkzeug ships a function that turns a client’s filename into something safe to put in a path. Before using it, see what it does to a few names:
# names.py
from werkzeug.utils import secure_filename
for name in ["me.png", "../me.png", "my photo.PNG", "café.png", ".."]:
print(f"{name!r:16} -> {secure_filename(name)!r}")
$ python names.py
'me.png' -> 'me.png'
'../me.png' -> 'me.png'
'my photo.PNG' -> 'my_photo.PNG'
'café.png' -> 'cafe.png'
'..' -> ''
Path separators become underscores, leading dots are stripped, spaces become underscores, and accents are folded to
ASCII. The last line is the catch: .. becomes an empty string, so the result still has to be
checked before it’s used. The extension check in the fix rejects an empty name, because it has no
extension.
The fix
Replace app.py. Clean the name, allow only image extensions, and let Flask enforce the size
limit:
# app.py
import os
from flask import Flask, abort, request
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config["UPLOAD_DIR"] = "uploads"
app.config["MAX_CONTENT_LENGTH"] = 1_000_000 # bytes; larger bodies get 413
ALLOWED = {".png", ".jpg", ".jpeg", ".gif"}
@app.post("/avatar")
def upload_avatar():
f = request.files["file"]
name = secure_filename(f.filename)
if os.path.splitext(name)[1].lower() not in ALLOWED:
abort(400)
f.save(os.path.join(app.config["UPLOAD_DIR"], name))
return {"saved": name}, 201
$ pytest -q --tb=short
.... [100%]
4 passed in 0.06s
All four pass. ../me.png is now saved as uploads/me.png. The allowlist names what is
accepted, so a type nobody thought of, such as .svg (which can carry script), is refused by
default. MAX_CONTENT_LENGTH limits the whole request body: Flask parses the form lazily, so the
413 is raised the moment the view first touches request.files, before anything is saved.
The extension is still only a claim about the content. A file named photo.png can contain HTML,
and the practice tasks close that gap. Serve stored uploads with a fixed Content-Type, or from a
separate domain, so a browser never runs one as a page.
Practice
- With the fixed app, what does an upload named
..return?
Solution
secure_filename("..")is the empty string,os.path.splitext("")gives an empty extension, and the empty extension isn’t inALLOWED:
def test_dotdot_rejected(client):
assert upload(client, "..").status_code == 400
- Two users both upload
me.png, and the second overwrites the first. Store each upload under a random name instead, and test that two uploads produce two files.
Solution
Keep the validated extension and replace the rest with a UUID (add import uuid at the top):
ext = os.path.splitext(name)[1].lower()
if ext not in ALLOWED:
abort(400)
stored = uuid.uuid4().hex + ext
f.save(os.path.join(app.config["UPLOAD_DIR"], stored))
return {"saved": stored}, 201
def test_two_uploads_two_files(client, tmp_path):
upload(client, "me.png")
upload(client, "me.png")
assert len(list((tmp_path / "uploads").iterdir())) == 2
A random name also means the client’s filename no longer reaches the disk at all, which removes a whole class of bugs.
- Reject a
.pngupload whose content doesn’t start with the PNG signature, and test it withupload(client, "x.png", b"<html>hi</html>").
Solution
Read the first 8 bytes, compare, then rewind so save writes the whole file:
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
if ext == ".png" and f.stream.read(8) != PNG_MAGIC:
abort(400)
f.stream.seek(0)
def test_html_named_png_rejected(client):
assert upload(client, "x.png", b"<html>hi</html>").status_code == 400
It passes, and the real PNG signature in the upload helper’s default still gets through.
Without seek(0), the saved file would be missing its first 8 bytes.
- After a 2 MB upload is refused with 413, is anything left in the upload folder? Write the test.
Solution
def test_413_leaves_no_file(client, tmp_path):
assert upload(client, "big.png", b"x" * 2_000_000).status_code == 413
assert list((tmp_path / "uploads").iterdir()) == []
It passes: the 413 is raised on the view’s first line, when request.files parses the oversized
body, so save is never reached.