Безопасная загрузка файлов в Django: инструкция с нуля
Защищённая загрузка файлов в Django начинается не с проверки расширения. Разберём рабочую схему для Django 5.2: ограничения размера, проверку содержимого, закрытое хранилище и выдачу файлов только авторизованным пользователям.

К концу этой инструкции у вас будет загрузка PDF и PNG в Django 5.2, которая не полагается на имя файла и MIME-тип из браузера, хранит данные вне публичной директории и отдаёт их только владельцу. На базовую настройку уйдёт около 30 минут, если проект уже создан и подключён к базе данных.
Что подготовить до начала
- Python 3.12 и Django 5.2;
- готовый Django-проект с приложением, например
files; - авторизацию пользователей;
- доступ к каталогу проекта для создания закрытой директории загрузок;
- миграции и подключённую базу данных.
В примере разрешены только PDF и PNG размером до 10 МБ. Эти значения можно изменить, но сначала зафиксируйте их как продуктовые правила, а не как случайные ограничения в коде.
Безопасный файл — это не файл с правильным расширением. Это файл с ограниченным размером, проверенным содержимым, случайным именем и контролируемой выдачей.
Пояснение к термину: MIME-тип — значение вроде application/pdf или image/png, которое описывает формат файла. Браузер присылает его вместе с загрузкой, но доверять ему нельзя: клиент может отправить любое значение.
Шаг 1. Уберите загрузки из публичной директории
Не кладите пользовательские файлы в каталог, который веб-сервер раздаёт напрямую. Иначе URL к файлу может обойти проверку доступа, а ошибка настройки сервера превратит загруженный скрипт в исполняемый.
Добавьте в settings.py:
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
PRIVATE_UPLOAD_ROOT = BASE_DIR / "private_uploads"
Не добавляйте PRIVATE_UPLOAD_ROOT в STATICFILES_DIRS, не настраивайте для него публичный URL и не указывайте этот каталог в конфигурации Nginx как alias.
Проверка: после запуска проекта рядом с файлом базы данных или другими файлами проекта существует каталог private_uploads, но открыть его через обычный URL нельзя.
Шаг 2. Создайте модель со случайным именем
Исходное имя файла не должно попадать в путь хранения. В нём могут быть пробелы, служебные символы, персональные данные и попытки манипулировать путём. Для хранения используйте UUID, а расширение берите только после проверки разрешённого списка.
# files/models.py
from pathlib import Path
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.db import models
private_storage = FileSystemStorage(location=settings.PRIVATE_UPLOAD_ROOT)
def private_upload_path(instance, filename):
extension = Path(filename).suffix.lower()
return f"incoming/{uuid4().hex}{extension}"
class FileAsset(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="file_assets",
)
blob = models.FileField(
storage=private_storage,
upload_to=private_upload_path,
max_length=300,
)
created_at = models.DateTimeField(auto_now_add=True)
Проверка: выполните python manage.py makemigrations files, затем python manage.py migrate. В базе должна появиться таблица приложения, а в модели — поле blob.
Шаг 3. Проверьте размер, расширение и сигнатуру
Расширение и MIME-тип полезны как первая линия фильтрации, но не доказывают формат. Поэтому пример дополнительно проверяет первые байты файла: PDF начинается с %PDF-, PNG — с фиксированной сигнатуры.
# files/forms.py
from pathlib import Path
from django import forms
from django.core.exceptions import ValidationError
from .models import FileAsset
MAX_UPLOAD_SIZE = 10 * 1024 * 1024
ALLOWED_EXTENSIONS = {".pdf", ".png"}
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
class FileAssetForm(forms.ModelForm):
class Meta:
model = FileAsset
fields = ("blob",)
def clean_blob(self):
uploaded_file = self.cleaned_data["blob"]
extension = Path(uploaded_file.name).suffix.lower()
if uploaded_file.size > MAX_UPLOAD_SIZE:
raise ValidationError("File size exceeds 10 MB.")
if extension not in ALLOWED_EXTENSIONS:
raise ValidationError("Unsupported file extension.")
header = uploaded_file.read(16)
uploaded_file.seek(0)
if extension == ".pdf" and not header.startswith(b"%PDF-"):
raise ValidationError("The file is not a valid PDF.")
if extension == ".png" and header != PNG_SIGNATURE:
raise ValidationError("The file is not a valid PNG.")
return uploaded_file
Проверка: перед сохранением отправьте PDF, PNG, переименованный текстовый файл и файл больше 10 МБ. Первые два должны пройти, остальные — получить сообщение формы.
Шаг 4. Принимайте файл только после авторизации
Форма не должна работать для анонимного пользователя. Сохраняйте владельца из текущей сессии, а не из поля формы или параметра запроса.
# files/views.py
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import render
from .forms import FileAssetForm
@login_required
def upload_file(request):
if request.method == "POST":
form = FileAssetForm(request.POST, request.FILES)
if form.is_valid():
asset = form.save(commit=False)
asset.owner = request.user
asset.save()
return JsonResponse({"id": asset.pk}, status=201)
else:
form = FileAssetForm()
return render(request, "files/upload.html", {"form": form})
Добавьте маршруты:
# files/urls.py
from django.urls import path
from .views import upload_file
urlpatterns = [
path("upload/", upload_file, name="upload_file"),
]
Подключите files.urls в корневом urls.py. Проверка: неавторизованный пользователь должен попасть на страницу входа, а авторизованный — увидеть форму и получить JSON с числовым идентификатором после успешной загрузки.
Шаг 5. Выдавайте файл через проверку владельца
Публичная ссылка на имя файла здесь не нужна. Сервер сначала проверяет пользователя и только потом открывает объект из закрытого хранилища.
# files/views.py
from django.contrib.auth.decorators import login_required
from django.http import FileResponse, Http404
from .models import FileAsset
@login_required
def download_file(request, asset_id):
try:
asset = FileAsset.objects.get(pk=asset_id, owner=request.user)
except FileAsset.DoesNotExist as error:
raise Http404 from error
response = FileResponse(
asset.blob.open("rb"),
as_attachment=True,
filename="downloaded-file",
content_type="application/octet-stream",
)
response["X-Content-Type-Options"] = "nosniff"
return response
Добавьте маршрут path("download/<int:asset_id>/", download_file, name="download_file"). Проверка: владелец скачивает файл, другой авторизованный пользователь получает 404, а прямого URL из private_uploads не существует.
Если не получилось
- «The submitted file is empty.» — браузер отправил пустой файл или форма не содержит
enctype="multipart/form-data". Проверьте HTML-форму и поле с типомfile. - «Unsupported file extension.» — расширение приводится к нижнему регистру, но отсутствует в
ALLOWED_EXTENSIONS. Не добавляйте формат только ради обхода ошибки: сначала реализуйте его проверку. - «The file is not a valid PDF.» — файл переименовали в PDF, но его содержимое другим осталось. Это ожидаемый отказ.
- «Permission denied» при сохранении — процесс Django не может писать в
PRIVATE_UPLOAD_ROOT. Исправьте права каталога от имени администратора системы, не выдавая проекту лишние права. - 404 при скачивании своим пользователем — проверьте, что в запросе используется тот же пользователь, который записан в поле
owner, и что маршрут подключён без другого префикса.
Где эта схема ломается
Проверка сигнатуры защищает от простого переименования файла, но не от всех вредоносных данных. PDF может содержать активное содержимое, а изображение — специально подготовленный файл, который атакует библиотеку обработки.
Для публичного сервиса добавьте антивирусную проверку до публикации файла, квоту на пользователя, журнал загрузок и асинхронное сканирование. Изображения безопаснее пересохранять серверной библиотекой в разрешённый формат, удаляя метаданные. Архивы лучше не принимать вовсе, пока не появятся лимиты на число файлов, распаковку и общий объём: архивная бомба может занять диск даже при маленьком размере загрузки.
Не используйте в этой схеме пользовательское имя как часть SQL-запроса, пути или HTML без экранирования. Инвалидация файловых ссылок и антивирусное сканирование — отдельные темы; здесь важны границы приёма, хранения и выдачи.
Что делать дальше
Сначала добавьте тесты на четыре сценария: разрешённый PDF, переименованный текстовый файл, превышение размера и скачивание чужого файла. Затем включите мониторинг свободного места и количества отказов формы. Если файлы будут храниться в S3-совместимом объектном хранилище, сохраните ту же модель доступа, но выдавайте короткоживущую подписанную ссылку только после проверки владельца.
Антиблок-аналитик 115-ФЗ: Анализ выписок, поиск рисков блокировки счёта, инструкции по безопасным транзакциям.
Спросить за 41 ₽Источники: Django 5.2 documentation: File Uploads, OWASP File Upload Cheat Sheet
Частые вопросы
Можно ли проверять только расширение файла?
Нужно ли хранить пользовательские файлы вне MEDIA_ROOT?
Достаточно ли проверки первых байтов PDF или PNG?
Что делать, если у меня другая версия Django?
Почему загрузка прошла, но файл нельзя скачать?
Нужно ли проверять MIME-тип из request.FILES?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.