Skip to content

CWE-434: Unrestricted Upload of File with Dangerous Type - Python

Overview

Flask, Django, and FastAPI all give the application access to the uploaded file's client-supplied name and content type - request.files['x'].content_type (Flask), uploaded_file.content_type (Django), and UploadFile.content_type (FastAPI) are copied from the multipart request headers, and none of these frameworks verifies them against the file's actual bytes.

A common misconception is that werkzeug.utils.secure_filename() (used directly in Flask, and functionally similar to Django's get_valid_filename()) performs type validation. It does not - it sanitizes the filename string, stripping path separators and unsafe characters so the name is safe to use as a filename, and says nothing about the file's actual content. Pair it with a separate content check: detect the real type with python-magic, or verify images specifically with Pillow's own parsing.

Common Vulnerable Patterns

Trusting content_type and the Filename Extension (Flask)

from flask import Flask, request
from werkzeug.utils import secure_filename
import os

app = Flask(__name__)
UPLOAD_DIR = os.path.join(app.static_folder, 'uploads')  # VULNERABLE - inside static folder

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']

    # VULNERABLE - content_type is a client-supplied multipart part header
    if file.content_type not in ('image/png', 'image/jpeg'):
        return 'Invalid file type', 400

    # VULNERABLE - secure_filename() only sanitizes the string; it performs
    # no content validation, and the sanitized client-supplied name is still
    # used as the storage name
    filename = secure_filename(file.filename)
    file.save(os.path.join(UPLOAD_DIR, filename))
    return 'uploaded'

# Attack: multipart part sends Content-Type: image/png and
# filename="shell.py" but the body bytes are executable/script content.
# secure_filename('shell.py') returns 'shell.py' unchanged - it has nothing
# to say about whether the *content* is safe.

Why this is vulnerable: file.content_type reflects only what the client claimed in the request; Flask never checks it against the bytes that follow. secure_filename() is a string sanitizer, not a content validator - it prevents ../etc/passwd-style names but happily returns shell.py or shell.php unchanged if that is what the client sent, and the file is then saved inside the app's static folder where it becomes directly requestable.

Trusting content_type in Django

from django.core.files.storage import default_storage

def upload_view(request):
    uploaded_file = request.FILES['file']

    # VULNERABLE - content_type is client-supplied, not verified
    if uploaded_file.content_type not in ('image/png', 'image/jpeg'):
        return HttpResponseBadRequest('Invalid file type')

    # VULNERABLE - FileField/default_storage controls the subpath but does
    # not validate content; the original name is used as-is
    path = default_storage.save(uploaded_file.name, uploaded_file)
    return HttpResponse(path)

Why this is vulnerable: FileField/default_storage.save() handle where a file is written but perform no content inspection. uploaded_file.content_type is the same client-supplied header problem as in Flask, and without a validators=[...] callable performing a real content check, a mismatched or malicious file passes straight through.

Trusting content_type in FastAPI

from fastapi import FastAPI, UploadFile

app = FastAPI()

@app.post('/upload')
async def upload(file: UploadFile):
    # VULNERABLE - content_type comes from the client-supplied Content-Type header
    if file.content_type not in ('image/png', 'image/jpeg'):
        return {'error': 'Invalid file type'}

    contents = await file.read()
    with open(f'static/uploads/{file.filename}', 'wb') as f:  # VULNERABLE - client filename, static dir
        f.write(contents)
    return {'status': 'ok'}

Why this is vulnerable: UploadFile.content_type is populated from the request's multipart headers with no server-side verification. file.filename is used directly as the storage path with no sanitization at all, so it is vulnerable to both content-type spoofing and path traversal.

Secure Patterns

Magic-Byte Validation, Generated Filename, Storage Outside the Web Root

import os
import uuid
import magic  # python-magic

# SECURE - outside STATIC_ROOT/MEDIA_ROOT or any static file directory
UPLOAD_DIR = "/var/app-data/uploads"
ALLOWED_TYPES = {"image/png": "png", "image/jpeg": "jpg"}
MAX_BYTES = 5 * 1024 * 1024

def store_upload(file_storage) -> str:
    data = file_storage.read(MAX_BYTES + 1)
    if len(data) > MAX_BYTES:
        raise ValueError("File too large")

    # SECURE - detect the real type from the bytes, not the client-supplied
    # content_type
    detected_type = magic.from_buffer(data, mime=True)
    if detected_type not in ALLOWED_TYPES:
        raise ValueError(f"Unsupported file type: {detected_type}")

    # SECURE - server-generated storage name; the client-supplied filename
    # (even after secure_filename()) is never used as the storage path
    stored_name = f"{uuid.uuid4()}.{ALLOWED_TYPES[detected_type]}"
    target_path = os.path.join(UPLOAD_DIR, stored_name)

    with open(target_path, "xb") as f:  # 'x' fails if the path already exists
        f.write(data)

    return stored_name

Why this works: magic.from_buffer() inspects the file's actual leading bytes against libmagic's signature database, so the accept/reject decision is independent of anything the client claimed in content_type - a forged header has no effect. The stored filename comes entirely from uuid.uuid4(), so the original client-supplied name (traversal sequences, double extensions, unusual characters) never becomes part of a filesystem path; secure_filename() is still useful, but only as a defence-in-depth sanitizer on a display name, not as the storage path. Opening with mode "xb" fails rather than silently overwriting on a collision, and the directory sits outside any static file root, so a file that reached disk cannot be served or executed through a direct request.

Serving Uploaded Files Back Safely

import re

STORED_NAME_PATTERN = re.compile(r"^[0-9a-f-]{36}\.(png|jpg)$")

@app.route('/files/<file_id>')
@login_required
def download(file_id):
    # SECURE - file_id is validated against the exact format the server
    # generates, then used only as a lookup key
    if not STORED_NAME_PATTERN.fullmatch(file_id):
        abort(404)
    if not current_user_can_access(file_id):
        abort(403)

    return send_from_directory(
        UPLOAD_DIR, file_id,
        as_attachment=True,               # forces download, not inline render
        mimetype="application/octet-stream",
    )

Why this works: as_attachment=True sets Content-Disposition: attachment, so the browser downloads the file instead of rendering it inline, which prevents a stored file from executing as HTML/SVG/script even if something slipped past the upload-time check. Validating file_id with fullmatch() against the exact generator pattern (not match(), which only anchors the start) ensures the value can only resolve to a file the application itself created.

Framework-Specific Guidance

Django: Content Validator on FileField

# validators.py
import magic
from django.core.exceptions import ValidationError

# SECURE - one mapping owns both halves of the decision: which types are allowed,
# and which extension each one is stored under
ALLOWED_TYPES = {"image/png": "png", "image/jpeg": "jpg"}

def detect_type(uploaded_file):
    # SECURE - read the real content instead of trusting uploaded_file.content_type
    head = uploaded_file.read(2048)
    uploaded_file.seek(0)
    return magic.from_buffer(head, mime=True)

def validate_file_content(uploaded_file):
    detected_type = detect_type(uploaded_file)
    if detected_type not in ALLOWED_TYPES:
        raise ValidationError(f"Unsupported file type: {detected_type}")
# models.py
import uuid
from django.db import models
from .validators import ALLOWED_TYPES, detect_type, validate_file_content

def generate_upload_path(instance, filename):
    # SECURE - the extension comes from the sniffed type, not from `filename`.
    # The validator has already rejected anything outside ALLOWED_TYPES, so this
    # lookup cannot fail for a file that reaches here.
    detected_type = detect_type(instance.file)
    return f"uploads/{uuid.uuid4()}.{ALLOWED_TYPES[detected_type]}"

class Attachment(models.Model):
    file = models.FileField(upload_to=generate_upload_path, validators=[validate_file_content])

Why this works: FileField(upload_to=...) controls only the storage subpath, not content safety, so pairing it with a validators=[...] callable that inspects real bytes closes the gap. The part that is easy to get wrong is the extension. Deriving it from filename - filename.rsplit('.', 1)[-1] is the usual spelling - means the content check and the stored name disagree: measured on Django 6.1, a genuine PNG uploaded as evil.php passes the validator and is written to uploads/<uuid>.php, and the same file uploaded as evil.jsp, x.aspx or shell lands as .jsp, .aspx and .shell. The UUID stops the client naming the file, and then the extension hands the interesting half of the name straight back. Taking the extension from detect_type() is what makes the two agree by construction.

Two things to check when adapting this. generate_upload_path receives the model instance, so instance.file is the same UploadedFile the validator saw - if your field is named something else, or the object is built without one, read the file from the argument you do have rather than leaving the extension to filename. And validators=[...] runs during full_clean(), which ModelForm.is_valid() triggers but Model.objects.create() does not, so on a non-form code path generate_upload_path would be reached with an unvalidated type and the ALLOWED_TYPES lookup would raise KeyError. That fails closed, but a ValidationError from an explicit check reads better in a log.

Flask: MAX_CONTENT_LENGTH

app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024  # SECURE - reject oversized requests early

Why this works: Flask rejects any request whose body exceeds MAX_CONTENT_LENGTH before the view function runs, so an oversized upload is bounced without ever reaching application code or being fully buffered.

FastAPI: Streaming Validation with python-multipart

from fastapi import FastAPI, UploadFile, HTTPException
import magic
import uuid

app = FastAPI()
ALLOWED_TYPES = {"image/png": "png", "image/jpeg": "jpg"}
MAX_BYTES = 5 * 1024 * 1024

@app.post('/upload')
async def upload(file: UploadFile):
    data = await file.read(MAX_BYTES + 1)
    if len(data) > MAX_BYTES:
        raise HTTPException(status_code=413, detail="File too large")

    # SECURE - detect the real type from the bytes, not file.content_type
    detected_type = magic.from_buffer(data, mime=True)
    if detected_type not in ALLOWED_TYPES:
        raise HTTPException(status_code=400, detail=f"Unsupported file type: {detected_type}")

    stored_name = f"{uuid.uuid4()}.{ALLOWED_TYPES[detected_type]}"
    with open(f"/var/app-data/uploads/{stored_name}", "xb") as f:
        f.write(data)

    return {"id": stored_name}

Why this works: Reading a bounded number of bytes (MAX_BYTES + 1) lets the handler reject an oversized file immediately without buffering the entire body, while magic.from_buffer() again makes the type decision from content rather than UploadFile.content_type.

Testing

  • Normal inputs: upload genuine PNG and JPEG files within the size limit; confirm both are accepted and retrievable through the download view.
  • Double extension: upload invoice.pdf.py with real PDF bytes and with real script bytes; confirm acceptance depends on the python-magic-detected type, not the filename.
  • MIME-type spoofing: submit Content-Type: image/png in the multipart part while the body is script or executable content; confirm rejection, since content_type is never consulted for the decision.
  • Path traversal: set the filename to ../../../etc/passwd and its URL-encoded form; confirm the stored path always resolves inside the configured upload directory (server-generated names make this structurally impossible).
  • Oversized file: upload past MAX_CONTENT_LENGTH/the FastAPI read cap; confirm rejection before the full body is processed.
  • Rescan: re-run any scanner or integration test against the fixed endpoint to confirm the finding no longer reproduces.

Common Pitfalls

  • Treating secure_filename() as a security control against malicious content: it only makes a string safe to use as a filename (no path separators, no .., no control characters) - it does not open, parse, or otherwise inspect the file, so secure_filename('shell.php') still returns exactly shell.php unless the storage name is separately generated.
  • Checking content_type in addition to the extension and treating that as "two checks": both values come from the same untrusted multipart headers - agreement between them proves nothing about the actual bytes, since an attacker can set both fields to whatever they like.
  • Validating content type in a Django clean() method but not in the model's save() path when objects are created outside a ModelForm: validators attached via validators=[...] on a field run during full_clean(), which ModelForm.is_valid() triggers - but code that calls Model.objects.create() or .save() directly bypasses full_clean() entirely, so the check silently does not run.

Dependencies and Installation

pip install python-magic          # Linux/macOS - requires libmagic (usually preinstalled)

# On Windows, python-magic needs a libmagic DLL. The python-magic-bin package
# that used to supply one has not been released since 2017; prefer installing
# libmagic through MSYS2/vcpkg, or use puremagic, which is pure Python and
# needs no native library:
pip install puremagic

python-magic wraps the system libmagic library. Keep it current along with its underlying libmagic package, since signature database updates and parsing fixes land in both.

Additional Resources