Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Django  Module 5 – Forms, validation and file uploads

File uploads and storage, done safely

Accept Django file uploads with FileField and ImageField, check size, type and name without trusting the client, and store and serve private files safely.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Handle uploads with FileField, ImageField and request.FILES in a multipart form
  • Validate an upload's size, type and name on the server without trusting what the client says
  • Store uploads through the storage API under names the server makes
  • Protect a private file behind a view that checks who is asking

Before you start

What you’ll learn · Before you start

What you will learn

  • Handle uploads with FileField, ImageField and request.FILES in a multipart form
  • Validate an upload's size, type and name on the server without trusting what the client says
  • Store uploads through the storage API under names the server makes
  • Protect a private file behind a view that checks who is asking

Before you start

On this page

A resident photographs a leaking pipe and attaches it to a complaint. Django’s upload settings move any file over 2,621,440 bytes to a temporary file on disk, but none of them refuses a file of 6,000,000 bytes, and when a PDF is named lift.jpg, the content type that comes with it says image/jpeg.

Everything about an upload comes from the client: its name, its type, its size and its bytes. This lesson takes the complaint photo of Nivas from the browser to the disk and checks it at each step: how files reach a view, which limits Django applies and which it leaves to you, how to tell what a file really is, where and under what name to store it, and how a resident’s private bill reaches that resident alone.

A form sends files only as multipart/form-data, into request.FILES

An HTML form sends files only when it is posted with enctype="multipart/form-data", and Django then puts them in request.FILES, apart from the other fields in request.POST. A form with a file field is bound to both, as the complaint view of Nivas does:

The complaint view and its template

complaints/views.py · nivas/complaints/views.py

from django.shortcuts import redirect, render

from .forms import ComplaintForm


def complaint_new(request):
    if request.method == 'POST':
        form = ComplaintForm(request.POST, request.FILES)  # files arrive in request.FILES, not request.POST
        if form.is_valid():
            complaint = form.save(commit=False)
            if 'photo' in request.FILES:
                complaint.original_name = request.FILES['photo'].name[:100]
            complaint.save()
            return redirect('complaints:new')
        status = 422
    else:
        form = ComplaintForm()
        status = 200
    return render(request, 'complaints/new.html', {'form': form}, status=status)

complaints/new.html · nivas/complaints/templates/complaints/new.html

<h1>Report a problem</h1>
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form }}
  <button>Send complaint</button>
</form>

This program builds upload requests with Django’s RequestFactory, the requests a browser would send, and prints what Django makes of each; the limits come into the next section:

Upload requests, and what request.FILES holds for each Python · nivas/upload_basics.py
"""Build upload requests with RequestFactory and look at what Django makes of each: request.FILES for a form posted
as multipart/form-data, nothing for one posted without it, and the limits Django's upload settings apply.

Run it next to manage.py."""

import io
import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.conf import settings  # noqa: E402
from django.core.exceptions import RequestDataTooBig, TooManyFilesSent  # noqa: E402
from django.core.files.uploadedfile import SimpleUploadedFile  # noqa: E402
from django.test import RequestFactory  # noqa: E402
from PIL import Image  # noqa: E402

factory = RequestFactory()
buffer = io.BytesIO()
Image.new('RGB', (640, 480), (90, 110, 130)).save(buffer, 'JPEG')
photo = buffer.getvalue()


def show(title, request):
    print(title)
    for key, upload in request.FILES.items():
        where = 'in memory' if not hasattr(upload, 'temporary_file_path') else 'in a temporary file on disk'
        print(f'  FILES[{key!r}]: {type(upload).__name__}, name={upload.name!r}, size={upload.size:,},',
              f'content_type={upload.content_type!r}, {where}')
    if not request.FILES:
        print('  FILES is empty; POST holds', dict(request.POST.items()))


print('Settings:', ', '.join(f'{name} = {getattr(settings, name):,}' for name in
                             ['FILE_UPLOAD_MAX_MEMORY_SIZE', 'DATA_UPLOAD_MAX_MEMORY_SIZE', 'DATA_UPLOAD_MAX_NUMBER_FILES']))
show('1. A photo of 640 x 480, posted as multipart/form-data',
     factory.post('/complaints/new/', {'flat': 'B-1203', 'photo': SimpleUploadedFile('lift.jpg', photo, 'image/jpeg')}))
show('2. A file of 3,000,000 bytes',
     factory.post('/complaints/new/', {'photo': SimpleUploadedFile('lift.jpg', b'\xff' * 3_000_000, 'image/jpeg')}))
show('3. A PDF whose browser said image/jpeg',
     factory.post('/complaints/new/', {'photo': SimpleUploadedFile('lift.jpg', b'%PDF-1.7\n%%EOF\n', 'image/jpeg')}))
show('4. The same form posted without enctype="multipart/form-data"',
     factory.post('/complaints/new/', 'flat=B-1203&photo=lift.jpg', content_type='application/x-www-form-urlencoded'))

many = factory.post('/complaints/new/', {'photo': [SimpleUploadedFile(f'{i}.jpg', b'x') for i in range(101)]})
try:
    many.FILES
except TooManyFilesSent as error:
    print('5. 101 files in one request:', type(error).__name__, f'({error}), which Django answers with 400')

text = factory.post('/complaints/new/', 'description=' + 'x' * 3_000_000, content_type='application/x-www-form-urlencoded')
try:
    text.POST
except RequestDataTooBig as error:
    print('6. 3,000,000 characters of form text:', type(error).__name__, f'({error})')

big = factory.post('/complaints/new/', {'photo': SimpleUploadedFile('lift.jpg', b'\xff' * 6_000_000, 'image/jpeg')})
print(f'7. One file of 6,000,000 bytes: parsed, {big.FILES["photo"].size:,} bytes; no upload setting limits the size of a file')

png = io.BytesIO()
Image.new('RGB', (8, 8)).save(png, 'PNG')
print('8. The first bytes of the files: the photo', photo[:4].hex(' '), '| a PNG', png.getvalue()[:8].hex(' '),
      '| a PDF', b'%PDF-1.7\n%%EOF\n'[:5].hex(' '), '(%PDF-)')

Output

Settings: FILE_UPLOAD_MAX_MEMORY_SIZE = 2,621,440, DATA_UPLOAD_MAX_MEMORY_SIZE = 2,621,440, DATA_UPLOAD_MAX_NUMBER_FILES = 100
1. A photo of 640 x 480, posted as multipart/form-data
  FILES['photo']: InMemoryUploadedFile, name='lift.jpg', size=5,428, content_type='image/jpeg', in memory
2. A file of 3,000,000 bytes
  FILES['photo']: TemporaryUploadedFile, name='lift.jpg', size=3,000,000, content_type='image/jpeg', in a temporary file on disk
3. A PDF whose browser said image/jpeg
  FILES['photo']: InMemoryUploadedFile, name='lift.jpg', size=15, content_type='image/jpeg', in memory
4. The same form posted without enctype="multipart/form-data"
  FILES is empty; POST holds {'flat': 'B-1203', 'photo': 'lift.jpg'}
5. 101 files in one request: TooManyFilesSent (The number of files exceeded settings.DATA_UPLOAD_MAX_NUMBER_FILES.), which Django answers with 400
6. 3,000,000 characters of form text: RequestDataTooBig (Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE.)
7. One file of 6,000,000 bytes: parsed, 6,000,000 bytes; no upload setting limits the size of a file
8. The first bytes of the files: the photo ff d8 ff e0 | a PNG 89 50 4e 47 0d 0a 1a 0a | a PDF 25 50 44 46 2d (%PDF-)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 upload_basics.py

Each file is an uploaded file: The object Django puts in request.FILES for each file of a multipart request: an InMemoryUploadedFile for a small file, a TemporaryUploadedFile on disk for a bigger one, with the name, size and content type the client sent. with a name, a size and a content_type. The first two requests show the two kinds Django makes: the photo of 5,428 bytes stayed in memory as an InMemoryUploadedFile, while the file of 3,000,000 bytes became a TemporaryUploadedFile, written to disk while it arrived. The third request is the warning of this whole lesson: a PDF arrived as lift.jpg with content_type='image/jpeg', because both are whatever the browser, or a script, wrote into the request. Django’s own documentation says not to trust the content type: “trust but verify”. The fourth request forgot the enctype: request.FILES stayed empty, and the file field arrived as the text lift.jpg in request.POST.

A file form without its enctype

<form method="post"> sends only the file’s name, so the form never sees a file: a required file field reports it missing, and an optional one, such as the complaint photo, is dropped without a word. Write <form method="post" enctype="multipart/form-data"> and bind the form with ComplaintForm(request.POST, request.FILES).

Checkpoint

Check: Multipart forms and request.FILES

  1. Question 1 of 2 A template has <form method="post"> with a file input and no enctype. What does the view find?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An empty request.FILES, and the file's name as text in request.POST

    A form that uploads files needs method="post" and enctype="multipart/form-data", and the view binds the form with request.POST and request.FILES.

  2. Question 2 of 2 An upload named lift.jpg arrives with content_type='image/jpeg'. What do those two values prove about the file?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing; both were written into the request by the client, so the content must be checked

    The name, the content type and even the size come from the request. The server learns what a file is from its bytes: Pillow for images, the format's signature for other files.

Django’s upload limits decide memory or disk, never a file’s size

Before a view runs, an upload passes several steps, and each can refuse it for a reason of its own. Step through the complaint photo from the browser to the database:

Interactive · stage by stage The way of one uploaded photo, and the check at each step Step through a complaint photo from the browser to the storage, and see what each step can refuse.
The way of one uploaded photo, and the check at each step, step 8 of 8. Showing The browser (lift.jpg, 30,790 bytes), The web server in front, The multipart parser, The upload handlers (in memory: 30,790 bytes), The form and the model (redrawn, without GPS), storage.save() (complaints/<hex>.jpg), The database row. In focus: The database row.422 with the errorThe browserlift.jpg, 30,790 bytesThe web server in frontits own body size limitThe multipart parserat most 100 filesThe upload handlersin memory: 30,790 bytesThe form and the modelredrawn, without GPSstorage.save()complaints/<hex>.jpgThe database rowthe file name only

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. The body. The form has enctype="multipart/form-data", so the browser sends the photo as one part of the body, with its file name and a content type that the browser chose.
  2. The first limit. The web server in front of Django sees the body first. Django's security guide strongly advises a size limit here, against denial of service, instead of relying on Django's upload settings, which never limit one file's size.
  3. Parts and counts. Django's multipart parser splits the body into fields and files. More than 100 files, DATA_UPLOAD_MAX_NUMBER_FILES, raises TooManyFilesSent, answered with 400.
  4. Memory or disk. Each file goes to the upload handlers: up to 2,621,440 bytes it stays in memory as an InMemoryUploadedFile; a bigger one is written to a temporary file on disk.
  5. The checks. ImageField opens the file with Pillow, so a PDF named lift.jpg fails; the extension must be jpg, jpeg or png; the size at most 5 MB. A failure goes back as a 422.
  6. Drawn again. A photo that passes is drawn again as a new JPEG before it is saved, so the stored file has no GPS position and nothing but the picture.
  7. A new name. storage.save() writes the file under MEDIA_ROOT with the name upload_to makes, complaints/ and 32 hex digits, never the name the phone sent.
  8. Only the name. The database row holds only that name, relative to MEDIA_ROOT. The file itself stays in the storage, and deleting the row does not delete it.

The numbers of the run above, beside the settings that decide them:

Setting (default) What it decides In the run
FILE_UPLOAD_MAX_MEMORY_SIZE (2,621,440) whether a file stays in memory or goes to a temporary file 3,000,000 bytes went to disk
DATA_UPLOAD_MAX_MEMORY_SIZE (2,621,440) the size of the body without its files 3,000,000 characters of text: RequestDataTooBig
DATA_UPLOAD_MAX_NUMBER_FILES (100) how many files one request may carry 101 files: TooManyFilesSent
none the size of one file 6,000,000 bytes parsed

So no setting limits the size of one file: the first only decides where it is kept, and the second, as Django’s settings reference says, leaves the file data out. A size limit therefore needs two places. The web server in front of Django refuses an oversized body before Django reads it, which Django’s security guide strongly advises, naming Apache’s LimitRequestBody; nginx’s client_max_body_size is 1 MB unless you raise it, and an oversized body gets 413. And the form refuses a file over its own limit with a message, as the next section’s validator does at 5 MB. Under ASGI, Django’s guide adds, the whole request may be written to disk before any file size is checked, one more reason for the limit in front.

Key idea

Django’s upload settings decide where a file waits and how many arrive, never how big one may be: limit the body at the web server and the file in the form.

Checkpoint

Check: What the upload settings limit

  1. Question 1 of 2 With the default settings, an upload of 3,000,000 bytes arrives. What does Django do with it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It writes it to a temporary file on disk, because it is over 2,621,440 bytes

    Small files stay in memory as InMemoryUploadedFile; bigger ones are streamed to a temporary file as TemporaryUploadedFile. Neither is a size limit.

  2. Question 2 of 2 The society wants photos of at most 5 MB. Where do the limits go?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In the web server in front, for the whole body, and in a validator on the photo field, for a message

    Two limits, two jobs: the web server stops bodies that are far too big, cheaply and before Django; the form's validator refuses a file over the society's rule with a message the resident understands.

Check what a file is, not what it claims

Complaint.photo is an ImageField with two validators, an extension allowlist and a size limit, and ComplaintForm draws every accepted photo again before it is stored:

The complaint model, its validator and its form

complaints/models.py · nivas/complaints/models.py

import uuid

from django.core.validators import FileExtensionValidator
from django.db import models

from .validators import validate_photo_size


def photo_path(instance, filename):
    """A name Django makes, never the one the resident's phone sent; the photo is stored as a JPEG."""
    return f'complaints/{uuid.uuid4().hex}.jpg'


class Complaint(models.Model):
    flat = models.CharField(max_length=6)
    description = models.TextField(max_length=500)
    photo = models.ImageField(
        upload_to=photo_path,
        blank=True,
        validators=[FileExtensionValidator(['jpg', 'jpeg', 'png']), validate_photo_size],
    )
    original_name = models.CharField(max_length=100, blank=True)  # shown to the resident, never used as a path

complaints/validators.py · nivas/complaints/validators.py

from django.core.exceptions import ValidationError

MAX_PHOTO_BYTES = 5 * 1024 * 1024  # 5 MB: a phone photo, with room to spare


def validate_photo_size(file):
    """Django's upload settings never limit the size of one file, so the form does."""
    if file.size > MAX_PHOTO_BYTES:
        raise ValidationError(
            'A photo can be at most 5 MB; this one is %(size).1f MB. Send a smaller photo.',
            code='file_too_large',
            params={'size': file.size / 1024 / 1024},
        )

complaints/forms.py · nivas/complaints/forms.py

import io

from django import forms
from django.core.files.base import ContentFile
from PIL import Image, ImageOps

from .models import Complaint


def redrawn_jpeg(upload):
    """The picture of an upload drawn again as a new JPEG, without the metadata a phone adds, such as where the photo
    was taken, and without anything else the file carried besides the picture."""
    image = ImageOps.exif_transpose(Image.open(upload))
    image.thumbnail((2000, 2000))
    out = io.BytesIO()
    image.convert('RGB').save(out, 'JPEG', quality=85)
    return ContentFile(out.getvalue(), name='photo.jpg')


class ComplaintForm(forms.ModelForm):
    class Meta:
        model = Complaint
        fields = ['flat', 'description', 'photo']

    def save(self, commit=True):
        # is_valid() has checked the upload itself: ImageField opened it with Pillow, and the model's validators
        # checked its name and size. Only now is it replaced by the redrawn copy that is stored.
        complaint = super().save(commit=False)
        if 'photo' in self.changed_data and complaint.photo:
            complaint.photo = redrawn_jpeg(complaint.photo)
        if commit:
            complaint.save()
        return complaint

This program sends the form eight submissions, from a phone photo that records where it was taken to one with no photo at all:

Eight submissions checked by ComplaintForm Python · nivas/complaint_photo.py
"""Send ComplaintForm eight submissions: a real photo with the place it was taken in its metadata, a PDF named
lift.jpg, the photo named lift.php and lift.jpg.html, a real GIF, the photo with 6,000,000 bytes added, a PNG, and one
with no photo at all.

Run it next to manage.py. It makes a test database in memory, as manage.py test does, and stores the photos under
MEDIA_ROOT."""

import io
import os
import re

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.core.files.uploadedfile import SimpleUploadedFile  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402
import PIL  # noqa: E402
from PIL import ExifTags, Image  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})

from complaints.forms import ComplaintForm  # noqa: E402


def photo_bytes(fmt='JPEG', size=(1600, 1200), with_place=False):
    image = Image.new('RGB', size, (180, 150, 120))
    exif = Image.Exif()
    if with_place:
        exif[ExifTags.Base.Make] = 'PhoneMaker'
        exif[ExifTags.IFD.GPSInfo] = {ExifTags.GPS.GPSLatitudeRef: 'N', ExifTags.GPS.GPSLatitude: (18.0, 31.0, 12.0),
                                      ExifTags.GPS.GPSLongitudeRef: 'E', ExifTags.GPS.GPSLongitude: (73.0, 51.0, 24.0)}
    out = io.BytesIO()
    image.save(out, fmt, exif=exif)
    return out.getvalue()


def place_of(data):
    gps = Image.open(io.BytesIO(data)).getexif().get_ifd(ExifTags.IFD.GPSInfo)
    return f'GPS {float(gps[2][0]):.0f}°{gps[1]} {float(gps[4][0]):.0f}°{gps[3]}' if gps else 'no GPS'


jpeg = photo_bytes(with_place=True)
cases = [
    ('a phone photo, 1600 x 1200', 'lift.jpg', jpeg, 'image/jpeg'),
    ('a PDF named lift.jpg', 'lift.jpg', b'%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n', 'image/jpeg'),
    ('the photo named lift.php', 'lift.php', jpeg, 'image/jpeg'),
    ('the photo named lift.jpg.html', 'lift.jpg.html', jpeg, 'text/html'),
    ('a real GIF named lift.gif', 'lift.gif', photo_bytes('GIF', (320, 240)), 'image/gif'),
    ('the photo with 6,000,000 bytes added', 'lift.jpg', jpeg + bytes(6_000_000), 'image/jpeg'),
    ('a PNG screenshot', 'leak.png', photo_bytes('PNG', (800, 600)), 'image/png'),
]
for number, (title, name, data, content_type) in enumerate(cases, start=1):
    form = ComplaintForm({'flat': 'B-1203', 'description': 'Water on the floor of lift B.'},
                         {'photo': SimpleUploadedFile(name, data, content_type)})
    print(f'{number}. {title}: {len(data):,} bytes' + (f', {place_of(data)}' if number == 1 else ''))
    if form.is_valid():
        complaint = form.save()
        with complaint.photo.open('rb') as stored:
            kept = stored.read()
        image = Image.open(io.BytesIO(kept))
        print('   valid; stored as', re.sub(r'[0-9a-f]{32}', '<32 hex digits>', complaint.photo.name) + ',',
              f'{image.format} {image.size}, {len(kept):,} bytes, {place_of(kept)}')
    else:
        for error in form.errors.as_data()['photo']:
            allowed = (error.params or {}).get('allowed_extensions', '').split(', ')
            if len(allowed) > 3:
                print(f"   refused by Django's image check: {error.code}, {len(allowed)} extensions allowed by Pillow",
                      f"{PIL.__version__},", 'pdf and eps among them' if {'pdf', 'eps'} <= set(allowed) else '')
            else:
                print(f'   refused: {error.code}: {error.messages[0]}')

form = ComplaintForm({'flat': 'B-1203', 'description': 'The lift light is out.'})
print('8. no photo: valid' if form.is_valid() else '8. no photo: refused', '| photo:', repr(form.cleaned_data['photo']))

Output

1. a phone photo, 1600 x 1200: 30,790 bytes, GPS 18°N 73°E
   valid; stored as complaints/<32 hex digits>.jpg, JPEG (1600, 1200), 30,629 bytes, no GPS
2. a PDF named lift.jpg: 51 bytes
   refused: invalid_image: Upload a valid image. The file you uploaded was either not an image or a corrupted image.
3. the photo named lift.php: 30,790 bytes
   refused by Django's image check: invalid_extension, 70 extensions allowed by Pillow 12.3.0, pdf and eps among them
4. the photo named lift.jpg.html: 30,790 bytes
   refused by Django's image check: invalid_extension, 70 extensions allowed by Pillow 12.3.0, pdf and eps among them
5. a real GIF named lift.gif: 501 bytes
   refused: invalid_extension: File extension “gif” is not allowed. Allowed extensions are: jpg, jpeg, png.
6. the photo with 6,000,000 bytes added: 6,030,790 bytes
   refused: file_too_large: A photo can be at most 5 MB; this one is 5.8 MB. Send a smaller photo.
7. a PNG screenshot: 2,790 bytes
   valid; stored as complaints/<32 hex digits>.jpg, JPEG (800, 600), 8,229 bytes, no GPS
8. no photo: valid | photo: None

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 complaint_photo.py

Each check caught a different lie. The PDF named lift.jpg failed invalid_image: ImageField opens every file with Pillow, so the content decides, not the name. The photos named lift.php and lift.jpg.html failed Django’s own image check, which reads the last extension and allows the 70 that Pillow 12.3.0 knows, PDF and EPS among them. That list is why Complaint.photo adds an allowlist of three, which refused a real GIF; Django’s documentation warns that an extension alone never proves a type, since files can be renamed. The photo with 6,000,000 bytes added failed the size validator; its message says 5.8 MB because, like Django’s settings, it counts a megabyte as 1,048,576 bytes.

Content checks have limits, so the stored file is drawn again

Django’s security guide warns that a file with a valid PNG header followed by HTML passes Pillow’s check. So the form draws every accepted photo again as a new JPEG, the decode and re-encode step that OWASP’s File Upload Cheat Sheet advises for images: the stored file holds the decoded picture and none of the uploaded bytes. OWASP adds that rewriting is no guarantee, because the image library itself reads the hostile file, so keep Pillow up to date. The run shows the bonus for privacy: the GPS position in the photo’s Exif metadata, 18°N 73°E, was gone from the stored copy.

The order matters in that form. Pillow and the validators check the upload as it arrived during is_valid(), and save() replaces it with the drawn copy only afterwards. Drawing it inside clean_photo() would hand the model’s validators a clean JPEG named photo.jpg, so the allowlist and the size limit would check the copy, not the upload.

For files that cannot be drawn again, such as PDFs, compare the first bytes with the signature of the format, as the exercise of this lesson does: a JPEG starts with ff d8, a PNG with 89 50 4e 47 0d 0a 1a 0a and a PDF with %PDF-, as the first run printed. A signature is easy to fake, so OWASP counts it as one check among several, never the only one. Where the risk is high, scan files with an antivirus engine, such as the open-source ClamAV, before anyone downloads them.

Checkpoint

Check: Checking what a file really is

  1. Question 1 of 2 A PDF named lift.jpg is sent to ComplaintForm. Which check refuses it, and why does it work?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ImageField, with invalid_image, because it opens the bytes with Pillow and a PDF is not an image it can read

    Checks on names and declared types catch honest mistakes; only a check of the bytes catches a file that lies about what it is.

  2. Question 2 of 2 A colleague moves the drawing of the photo into clean_photo(), so it returns the new JPEG named photo.jpg. What breaks?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The model's allowlist and size validator then check the drawn copy, so a GIF or a 6 MB upload is no longer refused by them

    Check the upload as it arrived, and transform it only after every check has passed: in ComplaintForm, Pillow and the validators run during is_valid() and the drawing happens in save().

Image Metadata Viewer See the Exif metadata a photo carries, the GPS position among it, before you upload it anywhere.

Storage decides where a file lives and under what name

A FileField saves the file through a storage backend: The class that saves, opens, names and deletes the files of FileFields, chosen in the STORAGES setting: FileSystemStorage keeps them in a folder such as MEDIA_ROOT, other backends in other services. and keeps only its name in the database, relative to the storage’s folder. STORAGES['default'] chooses the backend, a FileSystemStorage in MEDIA_ROOT unless you configure another, and Nivas adds a second one, private, for files no URL may serve. Both folders lie outside the project folder, so a release that replaces the code never touches residents’ files:

The upload settings of Nivas Python · nivas/config/settings.py
"""
Django settings for config project.

Generated by 'django-admin startproject' using Django 6.1.2.

For more information on this file, see
https://docs.djangoproject.com/en/6.1/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.1/ref/settings/
"""

from pathlib import Path

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-this-key-is-for-this-lesson-only-make-your-own'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'complaints.apps.ComplaintsConfig',
    'documents.apps.DocumentsConfig',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'config.wsgi.application'


# Database
# https://docs.djangoproject.com/en/6.1/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}


# Password validation
# https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/6.1/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.1/howto/static-files/

STATIC_URL = 'static/'


# User uploads: outside the project folder, so a release that replaces the code never touches them.
# https://docs.djangoproject.com/en/6.1/topics/files/

MEDIA_ROOT = BASE_DIR.parent / 'media'
MEDIA_URL = 'media/'

STORAGES = {
    'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
    'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'},
    # Files only a view may hand out: no web server serves this folder.
    'private': {
        'BACKEND': 'django.core.files.storage.FileSystemStorage',
        'OPTIONS': {'location': BASE_DIR.parent / 'private-media'},
    },
}


# Email
# https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration

MAILERS = {
    'default': {
        'BACKEND': 'django.core.mail.backends.console.EmailBackend',
    },
}

This program saves files through the storage API and prints the names it chose; the random parts are written as what they are, because they differ on every run:

Names chosen by the storage, and names it refuses Python · nivas/storage_names.py
"""Store files through the storage API and print the names it uses: a name kept from the client, the same name a
second time, the name upload_to makes, and names that try to leave MEDIA_ROOT.

Run it next to manage.py. It stores the files under MEDIA_ROOT."""

import os
import re
from pathlib import Path

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.conf import settings  # noqa: E402
from django.core.files.base import ContentFile  # noqa: E402
from django.core.files.storage import default_storage  # noqa: E402
from django.db import models  # noqa: E402

from complaints.models import Complaint  # noqa: E402


def masked(name):
    """The random parts of a name, which differ on every run, written as what they are."""
    name = re.sub(r'[0-9a-f]{32}', '<32 hex digits>', name)
    return re.sub(r'_[A-Za-z0-9]{7}(\.\w+)$', r'_<7 random characters>\1', name)


print('MEDIA_ROOT is outside the project folder:', not Path(default_storage.location).is_relative_to(settings.BASE_DIR))
for name in ['notices/rules.pdf', 'notices/rules.pdf']:
    print(f'save({name!r}) ->', masked(default_storage.save(name, ContentFile(b'%PDF-1.7\n'))))

field = models.FileField(upload_to='complaints/')
field.set_attributes_from_name('photo')
print("upload_to='complaints/' for \"lift photo (1).JPG\" ->", field.generate_filename(None, 'lift photo (1).JPG'))
photo_field = Complaint._meta.get_field('photo')
print('upload_to=photo_path for "lift photo (1).JPG" ->', masked(photo_field.generate_filename(None, 'lift photo (1).JPG')))

for name in ['../config/settings.py', '/etc/hosts', 'complaints/../../manage.py']:
    try:
        default_storage.save(name, ContentFile(b'x'))
    except Exception as error:
        print(f'save({name!r}) -> {type(error).__name__}: {error}')

Output

MEDIA_ROOT is outside the project folder: True
save('notices/rules.pdf') -> notices/rules.pdf
save('notices/rules.pdf') -> notices/rules_<7 random characters>.pdf
upload_to='complaints/' for "lift photo (1).JPG" -> complaints/lift_photo_1.JPG
upload_to=photo_path for "lift photo (1).JPG" -> complaints/<32 hex digits>.jpg
save('../config/settings.py') -> SuspiciousFileOperation: Detected path traversal attempt in '../config/settings.py'
save('/etc/hosts') -> SuspiciousFileOperation: Detected path traversal attempt in '/etc/hosts'
save('complaints/../../manage.py') -> SuspiciousFileOperation: Detected path traversal attempt in 'complaints/../../manage.py'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 storage_names.py

Read the names from the top. The second notices/rules.pdf did not overwrite the first: the storage added an underscore and 7 random characters. With upload_to='complaints/', a name from the client is cleaned, lift photo (1).JPG becoming lift_photo_1.JPG, but it is still the client’s choice; Nivas’s photo_path ignores it and makes complaints/ plus 32 hex digits, so no two photos collide and no name says anything about the resident. The original name goes into original_name, to be shown, never used as a path. And every name that tried to leave the folder, ../config/settings.py, /etc/hosts, complaints/../../manage.py, was refused with SuspiciousFileOperation.

Two facts of the documentation complete the picture: deleting a row never deletes its file, so orphaned files need a cleanup job of their own, and another backend can keep files in an object storage service instead of a folder, through a package that provides one; check that its version supports your Django release before you depend on it.

Checkpoint

Check: Storage, names and paths

  1. Question 1 of 2 Two residents upload notices/rules.pdf through default_storage.save(). What names do the files get?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: notices/rules.pdf, then notices/rules_ plus 7 random characters .pdf

    Names collide, which is one reason Nivas's photo_path makes every name from 32 hex digits and keeps the client's name only as text in original_name.

  2. Question 2 of 2 A complaint with a photo is deleted. What happens to the photo's file?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It stays in the storage; deleting a row never deletes its files

    The row and the file live in different places, the database and the storage, and only the name links them.

A private file goes through a view that checks who asks

A resident’s maintenance bill is for that resident alone, so it lives in the private storage and leaves it only through a view. Step through a download by its owner and by another resident:

Interactive · message by message A private file, handed out by a view Step through the bill's download by its owner, then by another resident.
A private file, handed out by a view, step 6 of 6. Messages so far: Browser to download view: GET /documents/1/download/; download view to Private storage: open bills/bill.pdf; download view to Browser: 200, attachment; Browser to download view: GET /documents/1/download/; download view to Browser: 404 Not Found.Browsersigned in as ravidownload viewPrivate storageno URL serves itGET /documents/1/download/open bills/bill.pdf200, attachmentGET /documents/1/download/404 Not Foundnever opened

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The owner asks. Asha, signed in, follows the link to her maintenance bill. The browser sends GET /documents/1/download/.
  2. Who may have it. The view looks the document up by its id and its owner at once: get_object_or_404(Document, pk=1, owner=request.user). Asha owns it, so the row is found.
  3. The file is opened. The view opens the file through the storage, from the private-media folder, which no web server and no URL pattern serves.
  4. The answer. FileResponse streams the file with Content-Type application/pdf, Content-Length 2057 and Content-Disposition attachment, and the security middleware adds X-Content-Type-Options nosniff.
  5. Another resident asks. Ravi sends the same request with his own session. The lookup asks for document 1 owned by ravi, and finds nothing.
  6. 404, not 403. The view answers 404 Not Found, the answer for a document that does not exist, so ravi learns nothing about which documents exist, and the storage is never touched.

The document model and its download view

documents/models.py · nivas/documents/models.py

from django.conf import settings
from django.core.files.storage import storages
from django.db import models


def private_storage():
    return storages['private']


class Document(models.Model):
    """A file for one resident only, such as their maintenance bill."""

    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    title = models.CharField(max_length=80)
    file = models.FileField(upload_to='bills/', storage=private_storage)

documents/views.py · nivas/documents/views.py

from django.contrib.auth.decorators import login_required
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from django.utils.text import slugify

from .models import Document


@login_required
def download(request, pk):
    # The owner's own documents only: anyone else gets 404, as if the document did not exist.
    document = get_object_or_404(Document, pk=pk, owner=request.user)
    return FileResponse(document.file.open('rb'), as_attachment=True, filename=f'{slugify(document.title)}.pdf')

This program fetches the bill as asha, as ravi and signed out, then looks for it under MEDIA_URL:

One private bill, four requests Python · nivas/private_download.py
"""Fetch a resident's maintenance bill through the download view: as the owner, as another resident, and signed out;
then look for it under MEDIA_URL, where no URL serves it.

Run it next to manage.py. It makes a test database in memory, as manage.py test does, and stores the bill in the
private storage."""

import logging
import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.contrib.auth.models import User  # noqa: E402
from django.core.files.base import ContentFile  # noqa: E402
from django.test import Client  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
logging.getLogger('django.request').setLevel(logging.CRITICAL)  # the 404s below are expected

from documents.models import Document  # noqa: E402

asha = User.objects.create_user('asha', password='a long passphrase for asha')
ravi = User.objects.create_user('ravi', password='a long passphrase for ravi')
bill = Document(owner=asha, title='Maintenance bill B-1203')
bill.file.save('bill.pdf', ContentFile(b'%PDF-1.7\n' + b'0' * 2041 + b'\n%%EOF\n'))
print('stored as', repr(bill.file.name), 'in the private storage:', bill.file.storage.location.endswith('/private-media'))

for who in [asha, ravi, None]:
    client = Client()
    if who:
        client.force_login(who)  # the authentication module covers signing in
    response = client.get(f'/documents/{bill.pk}/download/')
    print(f'GET /documents/{bill.pk}/download/ as {who.username if who else "a visitor who is not signed in"}')
    print(f'  {response.status_code} {response.reason_phrase}')
    if response.status_code == 200:
        for header in ['Content-Type', 'Content-Length', 'Content-Disposition', 'X-Content-Type-Options']:
            print(f'  {header}: {response[header]}')
        response.close()
    elif response.status_code == 302:
        print(f'  Location: {response["Location"]}')

response = Client().get('/media/' + bill.file.name)
print(f'GET /media/{bill.file.name}: {response.status_code} {response.reason_phrase}; no URL serves the folder')

Output

stored as 'bills/bill.pdf' in the private storage: True
GET /documents/1/download/ as asha
  200 OK
  Content-Type: application/pdf
  Content-Length: 2057
  Content-Disposition: attachment; filename="maintenance-bill-b-1203.pdf"
  X-Content-Type-Options: nosniff
GET /documents/1/download/ as ravi
  404 Not Found
GET /documents/1/download/ as a visitor who is not signed in
  302 Found
  Location: /accounts/login/?next=/documents/1/download/
GET /media/bills/bill.pdf: 404 Not Found; no URL serves the folder

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 private_download.py

FileResponse set the headers from the file and the name: Content-Type: application/pdf, a Content-Length of 2,057 bytes, and Content-Disposition: attachment, which makes the browser save the file instead of showing it. X-Content-Type-Options: nosniff comes from Django’s security middleware and tells the browser to keep to that type. Ravi got 404, the answer for a document that does not exist, and the visitor who was not signed in was sent to log in. No URL serves the private folder, so /media/bills/bill.pdf was 404 too.

Kind of upload Serve it from Why
Private: bills, ID documents a view that checks the user, as above each request is checked; the web server never serves the folder
Private and large or many the same view answering with X-Accel-Redirect Django checks, then the web server sends the file from an internal location
Public: complaint photos on the board a separate domain, such as usercontent-example.com Django’s guide: a subdomain of your site is not enough

Never serve an upload from your site's own origin as a page

An upload served from your own domain with a type the browser renders, such as HTML, runs as part of your site. Serve public uploads from a separate domain, send nosniff, and send anything that is not an image as an attachment.

Checkpoint

Check: Serving private and public uploads

  1. Question 1 of 2 Ravi, signed in, requests the download URL of asha's maintenance bill. What does the view answer, and why that code?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 404, because the lookup asks for the document by id and by owner, and finds none of ravi's

    Choose the row by the user in the lookup itself, get_object_or_404(Document, pk=pk, owner=request.user), so a file is handed out only to its owner.

  2. Question 2 of 2 Why does the download view answer with Content-Disposition attachment and X-Content-Type-Options nosniff?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: So the browser saves the file instead of showing it, and keeps to the declared type instead of guessing one it would run as a page

    FileResponse with as_attachment=True sets Content-Disposition; Django's security middleware sends nosniff on every response. Public uploads also belong on a separate domain.

MIME Type Lookup Look up the Content-Type a file extension maps to, and the extension of a type.

Practise: check an upload by its bytes

Write check_upload(name, size, first_bytes, allowed), the checks a view can make before a file is stored: a plain file name, an extension from an allowlist, a size within 5 MB, and the first bytes of the format the extension names. Its eight sample tests run in your browser:

Exercise · Medium · Python

Check an upload without trusting the client

Nivas accepts photos and PDF documents. Write check_upload(name, size, first_bytes, allowed), which decides whether to accept one upload from what the server can see, never from the content type the browser claimed. It returns None for an upload it accepts, and otherwise the error code of the first check that fails, in this order:

  1. 'invalid_name' when the file name is empty, starts with a dot, or contains /, \ or the NUL character \x00.
  2. 'invalid_extension' when the text after the last dot of the name, in lower case, is not in allowed, a set such as {'jpg', 'jpeg', 'png'}; a name without a dot has no extension and fails too.
  3. 'empty_file' when size is 0, and 'file_too_large' when it is more than 5 MB, 5,242,880 bytes.
  4. 'wrong_content' when first_bytes, the first bytes of the file, do not start with the signature of the format the extension names: b'\xff\xd8\xff' for jpg and jpeg, b'\x89PNG\r\n\x1a\n' for png, and b'%PDF-' for pdf.

So check_upload('rules.pdf', 52_000, b'%PDF-1.7\n', {'pdf'}) returns None, while the same bytes named lift.jpg return 'wrong_content' when allowed is {'jpg', 'pdf'}, and 'invalid_extension' when it is {'pdf'}.

Starter code · upload.py

MAX_BYTES = 5 * 1024 * 1024


def check_upload(name, size, first_bytes, allowed):
    """None if the upload is acceptable, else the code of the first check that fails."""
    if name.split('.')[-1] not in allowed:
        return 'invalid_extension'
    return None
The sample tests · test_upload.py
from upload import check_upload

JPEG = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00'
PNG = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'
PDF = b'%PDF-1.7\n%\xe2\xe3\xcf\xd3\n'
PHOTOS = {'jpg', 'jpeg', 'png'}


def test_real_files_pass():
    assert check_upload('lift.jpg', 30_790, JPEG, PHOTOS) is None
    assert check_upload('lift.jpeg', 30_790, JPEG, PHOTOS) is None
    assert check_upload('screen.png', 2_790, PNG, PHOTOS) is None
    assert check_upload('rules.pdf', 52_000, PDF, {'pdf'}) is None


def test_the_extension_ignores_case():
    assert check_upload('LIFT.JPG', 30_790, JPEG, PHOTOS) is None


def test_an_extension_outside_the_allowlist():
    for name in ['lift.gif', 'lift', 'lift.jpg.php', 'lift.jpg.html', 'rules.pdf']:
        assert check_upload(name, 30_790, JPEG, PHOTOS) == 'invalid_extension', name


def test_names_that_are_not_plain_file_names():
    for name in ['', '.htaccess', '../lift.jpg', 'photos/lift.jpg', 'a\\lift.jpg', 'lift.php\x00.jpg']:
        assert check_upload(name, 30_790, JPEG, PHOTOS) == 'invalid_name', repr(name)


def test_the_size_limits():
    assert check_upload('lift.jpg', 0, JPEG, PHOTOS) == 'empty_file'
    assert check_upload('lift.jpg', 5_242_880, JPEG, PHOTOS) is None
    assert check_upload('lift.jpg', 5_242_881, JPEG, PHOTOS) == 'file_too_large'


def test_content_that_does_not_match_the_extension():
    assert check_upload('lift.jpg', 52_000, PDF, PHOTOS | {'pdf'}) == 'wrong_content'
    assert check_upload('lift.png', 30_790, JPEG, PHOTOS) == 'wrong_content'
    assert check_upload('rules.pdf', 52_000, b'<html><body>', {'pdf'}) == 'wrong_content'
    assert check_upload('lift.png', 30_790, b'\x89PNG', PHOTOS) == 'wrong_content'


def test_the_first_failing_check_wins():
    assert check_upload('.lift.gif', 30_790, JPEG, PHOTOS) == 'invalid_name'
    assert check_upload('lift.gif', 0, PDF, PHOTOS) == 'invalid_extension'
    assert check_upload('lift.jpg', 9_000_000, PDF, PHOTOS) == 'file_too_large'


def test_the_examples_of_the_prompt():
    assert check_upload('rules.pdf', 52_000, b'%PDF-1.7\n', {'pdf'}) is None
    assert check_upload('lift.jpg', 52_000, b'%PDF-1.7\n', {'jpg', 'pdf'}) == 'wrong_content'
    assert check_upload('lift.jpg', 52_000, b'%PDF-1.7\n', {'pdf'}) == 'invalid_extension'
A hint

Get the extension with name.rpartition('.'): its last part is the extension when the middle part is a dot, and 'lift'.rpartition('.') gives ('', '', 'lift'), no extension. Keep the signatures in a dictionary from extension to bytes, and compare with first_bytes.startswith(signature), which is also False when the file is shorter than its signature.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Last, a self-check on uploads: questions on the five ideas above, weakest first, each answer explained the moment you choose it:

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 How does a view bind a form that has a file field to a submission?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: ComplaintForm(request.POST, request.FILES)

    Bind the form to both dictionaries, and make sure the HTML form is sent as multipart/form-data.

  2. Question 2 of 11 Which attribute of an uploaded file can the server trust without any check?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: None of them; name and content_type come from the client, and size only says how much arrived

    Treat every part of an upload as input: the name only as text to show, the type as a claim, the size as a number to limit, and the bytes as the thing to check.

  3. Question 3 of 11 With the default settings, how many files may one request carry before Django raises TooManyFilesSent?

    Type a number.

    Show the answer: Question 3 of 11

    Answer: 100 files

    DATA_UPLOAD_MAX_NUMBER_FILES defaults to 100. The run's request with 101 files raised TooManyFilesSent, which Django answers with 400.

  4. Question 4 of 11 Nivas runs under ASGI, and the form has a 5 MB size validator. Why does Django's security guide still ask for a limit in the web server?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Under ASGI the whole request may be written to disk before any file size is checked, so a huge body costs disk and time first

    A validator can only refuse what has already arrived. A body limit in the server in front, such as nginx's client_max_body_size, refuses with 413 before Django spends anything on it.

  5. Question 5 of 11 ImageField already checks the extension. Why does Complaint.photo add FileExtensionValidator(['jpg', 'jpeg', 'png'])?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: Django's image check allows every extension Pillow knows, 70 of them with pdf and eps, and the society wants three

    An allowlist names exactly the formats a feature accepts. Django's documentation warns that an extension alone never proves a type, which is why the content is checked as well.

  6. Question 6 of 11 The first bytes of an upload named rules.pdf are 89 50 4e 47 0d 0a 1a 0a. What is it?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: A PNG image, whatever its name says

    Each format begins with its own signature: ff d8 for JPEG, 89 50 4e 47 0d 0a 1a 0a for PNG, %PDF- for PDF. A signature is a quick check of the bytes, not a full one.

  7. Question 7 of 11 What does drawing an accepted photo again as a new JPEG protect against that Pillow's check does not?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: Anything the file carried besides the picture, such as metadata with a GPS position or data hidden after a valid header

    Checks decide whether to accept a file; rewriting decides what is kept. OWASP's File Upload Cheat Sheet lists it among its content checks, with the caution that it removes much but not everything.

  8. Question 8 of 11 Why does photo_path build every name from uuid4().hex instead of using the file name the phone sent?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: The client's name can collide, reveal something, or try to leave the folder; a server-made name does none of that

    OWASP's File Upload Cheat Sheet recommends a random file name made by the server. Keep the client's name as data, if you need it, never as a path.

  9. Question 9 of 11 What does default_storage.save('../config/settings.py', content) do?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: It raises SuspiciousFileOperation, a path traversal attempt

    Django's file storage refuses relative paths that climb out of its folder and absolute paths alike. Server-made names keep such names from reaching it at all.

  10. Question 10 of 11 Complaint photos are shown on the public notice board. Where should they be served from?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: A separate domain, such as usercontent-example.com, not a subdomain of the site

    Private files go through a view; public uploads go to a domain of their own, so whatever a file holds, it can never act as a page of your site.

  11. Question 11 of 11 Residents download many large private documents, and each download keeps a Django worker busy while it streams. What can the view do instead?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: Check the user, then answer with X-Accel-Redirect so the web server sends the file from an internal location

    X-Accel-Redirect splits the work: Django decides who may have the file, and the web server, which is built for sending files, sends it.

Interview questions

Warm-up (fresher): A view finds request.FILES empty although the user chose a file. Why?

request.FILES holds files only when the request is a POST whose form was sent with enctype="multipart/form-data". A form with method="post" and no enctype is sent as application/x-www-form-urlencoded, which has no room for a file’s bytes, so the browser sends the file’s name as a plain field and request.FILES stays empty. A GET never carries files either. Fix the template’s form tag, and bind the form with both request.POST and request.FILES; a form that only gets request.POST never sees the file, even when it arrived, so a required file field reports it missing.

Two questions of this module’s bank start from these files: complaint photos for a whole society, and an upload that opened as a page of the site.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Nivas has 1,200 flats, and residents attach phone photos of 3 to 8 MB to complaints, up to 4 a complaint. Only the resident and the committee may see them. Design the upload path: the limits, the checks, where the files live, how they are served, and what you do about the location data in the photos.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A resident reports that a complaint attachment, opened from the notice board, showed a page asking for their Nivas password. The file was uploaded as a photo and is served from the site's own domain under /media/. How could that happen, and what do you change so that no upload can act as a page of the site again?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Files arrive in request.FILES only from a POST sent as multipart/form-data; each has a name, a size and a content type, and the name and the type are whatever the client wrote.
  • FILE_UPLOAD_MAX_MEMORY_SIZE decides memory or disk, DATA_UPLOAD_MAX_MEMORY_SIZE leaves files out, and nothing limits one file’s size, so limit the body at the web server and the file in the form.
  • Check the content, not the claim: ImageField opens the file with Pillow, an allowlist narrows the extensions, a size validator caps it, and drawing an image again strips anything but the picture.
  • Store files through the storage API under names the server makes, outside the project folder; the database keeps only the name, and names that leave the folder are refused.
  • Serve private files through a view that checks the user, as an attachment with nosniff, and public uploads from a separate domain.

Terms in this lesson

storage backend
The class that saves, opens, names and deletes the files of FileFields, chosen in the STORAGES setting: FileSystemStorage keeps them in a folder such as MEDIA_ROOT, other backends in other services.
uploaded file
The object Django puts in request.FILES for each file of a multipart request: an InMemoryUploadedFile for a small file, a TemporaryUploadedFile on disk for a bigger one, with the name, size and content type the client sent.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.