Skip to main content
Web Security (OWASP Top 10, fix-side) · lesson 5 of 6
Web Security (OWASP Top 10, fix-side) intermediate Lesson 5 of 6

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

web-security owasp file-upload path-traversal flask pytest

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

  1. With the fixed app, what does an upload named .. return?
Solution
  1. secure_filename("..") is the empty string, os.path.splitext("") gives an empty extension, and the empty extension isn’t in ALLOWED:
def test_dotdot_rejected(client):
    assert upload(client, "..").status_code == 400
  1. 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.

  1. Reject a .png upload whose content doesn’t start with the PNG signature, and test it with upload(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.

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

Frequently Asked Questions

Why is saving an uploaded file with its original filename dangerous?
The filename comes from the client and can contain path separators such as ../, so joining it to an upload folder can write the file somewhere else on the server. Flask and Werkzeug pass the name through unchanged in request.files. Clean it with werkzeug.utils.secure_filename, or ignore it and store the file under a random name.
What does Werkzeug's secure_filename do?
secure_filename turns a client-supplied name into one that is safe to use in a path. It replaces path separators with underscores, strips leading dots, replaces spaces with underscores, and converts non-ASCII characters to ASCII. It can return an empty string, for example for '..', so the result still needs checking before use.
Is checking the file extension enough to validate an upload?
No. An extension allowlist stops files such as .html or .php being stored with a dangerous name, but the extension says nothing about the content. A file named photo.png can contain HTML. Check the leading magic bytes for the types you accept, serve uploads from a separate domain or with a fixed Content-Type, and never execute them.
How do I limit upload size in Flask?
Set app.config['MAX_CONTENT_LENGTH'] to the largest request body you accept, in bytes. Flask then rejects a larger request with 413 Request Entity Too Large before the view saves anything. It limits the whole request body, so set it a little above the largest file you allow.
Should uploaded files keep the user's filename?
Usually not. Two users uploading me.png would overwrite each other, and the name can leak information or collide with other files. Store each upload under a random name such as a UUID plus the validated extension, and keep the original name in a database column if you need to show it.