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.
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:
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:
"""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
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
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:
"""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
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:
"""
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:
"""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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The owner asks. Asha, signed in, follows the link to her maintenance bill. The browser sends GET /documents/1/download/.
- 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.
- 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.
- 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.
- Another resident asks. Ravi sends the same request with his own session. The lookup asks for document 1 owned by ravi, and finds nothing.
- 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.
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:
"""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
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:
'invalid_name'when the file name is empty, starts with a dot, or contains/,\or the NUL character\x00.'invalid_extension'when the text after the last dot of the name, in lower case, is not inallowed, a set such as{'jpg', 'jpeg', 'png'}; a name without a dot has no extension and fails too.'empty_file'whensizeis 0, and'file_too_large'when it is more than 5 MB, 5,242,880 bytes.'wrong_content'whenfirst_bytes, the first bytes of the file, do not start with the signature of the format the extension names:b'\xff\xd8\xff'forjpgandjpeg,b'\x89PNG\r\n\x1a\n'forpng, andb'%PDF-'forpdf.
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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.
-
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?
Key takeaways
- Files arrive in
request.FILESonly from a POST sent asmultipart/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_SIZEdecides memory or disk,DATA_UPLOAD_MAX_MEMORY_SIZEleaves 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:
ImageFieldopens 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
- File uploads (Django Software Foundation)
- Uploaded files and upload handlers (Django Software Foundation)
- Managing files (Django Software Foundation)
- File storage API (Django Software Foundation)
- Model field reference: FileField and ImageField (Django Software Foundation)
- Validators: FileExtensionValidator (Django Software Foundation)
- Settings: file uploads (Django Software Foundation)
- Security in Django: user-uploaded content (Django Software Foundation)
- Request and response objects: FileResponse (Django Software Foundation)
- Middleware: X-Content-Type-Options (Django Software Foundation)
- Django exceptions: SuspiciousOperation (Django Software Foundation)
- File Upload Cheat Sheet (OWASP Foundation)
- nginx core module: client_max_body_size and internal (nginx)
- Portable Network Graphics (PNG) Specification: PNG signature (W3C)
- JFIF, JPEG File Interchange Format (format description) (Library of Congress)
- PDF (Portable Document Format) Family (format description) (Library of Congress)
- Pillow: ExifTags module (Pillow)
- ClamAV documentation (ClamAV)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress