1. Доступ ≠ безопасный файл
Если вы только что поймали себя на мысли «ну раз пользователь аутентифицирован, то он же наш, значит можно принимать его файл», поздравляю: вы мыслите как оптимистичный человек, который верит людям… а потом читает логи в 3 часа ночи. В upload-сценариях аутентификация и CSRF отвечают лишь на вопрос «кто делает запрос и имеет ли он право менять состояние», но они не отвечают на вопрос «что именно он прислал и сколько это будет стоить системе».
В аватарном кейсе у нас типичная “тройная защита”: доступ к endpoint’у, корректность CSRF и корректность самого файла. Эта лекция как раз про третий слой. Мы хотим, чтобы сервер не принимал «аватар» размером 200MB, не сохранял файл с именем ../../application.yml, и не позволял пользователю случайно (или “случайно”) устроить мини‑DoS на диске и памяти.
Два уровня ограничений: платформа и сценарий
В мире Spring Boot очень легко перепутать две похожие вещи: “лимиты multipart” и “наши бизнес‑правила для аватара”. Они обе про размер, обе про ограничения, обе «где-то в конфиге», но смысл разный. Multipart‑лимиты — это защита уровня платформы: они не дают вашему приложению вообще начать нормально обрабатывать слишком большой запрос. А прикладная валидация — это защита уровня сценария: она задаёт правила именно для аватара как сущности домена.
Удобно держать эту разницу в голове как маленькую таблицу. Она помогает не спорить с самим собой и не ловить странные баги, когда “у меня лимит 2MB в коде, но всё равно падает на 1MB”.
| Уровень | Где задаём | Что защищает | Типичный эффект |
|---|---|---|---|
| Multipart limit | application.yml → spring.servlet.multipart.* | Сервер/контейнер от слишком больших запросов | запрос не доходит до контроллера или падает ещё на разборе multipart |
| Avatar rules | код (validator/service) + возможно app.* настройки | бизнес‑сценарий “аватар” | контроллер получил MultipartFile, но мы решаем “подходит/не подходит” |
И практическое правило тут очень простое: multipart‑лимиты должны быть не выше ваших прикладных правил. Иначе вы будете получать ошибки “ещё до валидации”, а студент (и будущий вы) будут думать, что «валидация не работает».
3. Multipart-лимиты Spring Boot: дефолты и явность
Давайте начнём с того, что Spring Boot (в servlet / Spring MVC мире) уже умеет ограничивать multipart‑загрузки. Это не магия, а вполне прагматичная защита от “я случайно залил файл на 800MB и теперь сервер грустит”. В baseline Spring Boot обычно есть дефолтные значения: 1MB на файл и 10MB на весь запрос. Это хороший старт, но в учебном проекте важно сделать лимиты явными, чтобы позже никто не искал “почему 1MB”.
Самый простой способ — прописать лимиты в application.yml. Для аватара мы часто хотим держать размеры ещё более строгими (например, 2MB на файл и 2–3MB на запрос, потому что у нас один файл и пара текстовых полей, но не “фотоальбом за лето”).
spring:
servlet:
multipart:
# Платформенная защита: не даём приложению даже начать обрабатывать слишком большой файл
max-file-size: 2MB
# Лимит на весь multipart-запрос: файл + поля формы + служебные границы
max-request-size: 3MB
app:
storage:
# Директория хранения задаётся сервером (а не клиентом)
avatar-dir: ./storage/avatars
Здесь важно, что max-request-size — это лимит на весь multipart‑запрос, то есть файл плюс поля формы, плюс все границы и служебные части. Поэтому он обычно немного больше, чем max-file-size, иначе можно получить ситуацию “файл 2MB, но запрос 2MB+чуть-чуть, и всё падает”.
Что происходит при превышении? В типичном сценарии запрос ломается ещё на этапе обработки multipart и до контроллера вы можете вообще не дойти. Это нормально: платформа сказала “извините, слишком много” ещё до вашей бизнес‑логики. Именно поэтому мы всё равно делаем прикладную проверку размера — потому что она даёт более контролируемое поведение и не завязана на то, что именно и как выбросит контейнер.
4. Проверка размера без лишней памяти
Теперь переходим к прикладным правилам. Начнём с размера — это самый “честный” параметр, потому что он не про интерпретацию, а про ресурсы. Размер файла напрямую превращается в использование диска, времени на запись, потенциально — памяти, и вообще в «сколько стоит ваш endpoint». Для аватара нам нужна политика “маленький файл, быстрая обработка”.
Проверка размера в Java выглядит почти смешно просто: MultipartFile#getSize() возвращает количество байт. Но важно делать это до сохранения файла, а не после. Мы не хотим сначала записать 30MB на диск, а потом сказать “ой, нельзя”. Это как сначала пустить человека в клуб, а уже потом вспомнить “а в шортах нельзя”.
import org.springframework.web.multipart.MultipartFile;
public void validateAvatarSize(MultipartFile file) {
// Прикладной лимит: его легче объяснить пользователю и тестировать, чем ошибку контейнера
long maxBytes = 2 * 1024 * 1024; // 2MB
// Проверяем наличие файла как входного параметра сценария
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("Avatar file is required");
}
// Проверяем размер до сохранения на диск, чтобы не тратить ресурсы впустую
if (file.getSize() > maxBytes) {
throw new IllegalArgumentException("Avatar is too large");
}
}
Обратите внимание на маленькую деталь: isEmpty() — это не «размер 0». Это ещё и про ситуацию, когда поле file было в форме, но реально файл не приложили или он не прочитался. Для API это очень частая ошибка клиента, и приятно ловить её сразу, коротко и без сложных стек-трейсов.
5. Проверка типа: whitelist и “Content-Type” не истина
Если размер — это экономика ресурсов, то тип файла — это безопасность и корректность. Для аватара нас обычно устраивают очень конкретные форматы: image/png и image/jpeg. Всё остальное — либо лишнее, либо потенциально проблемное. Например, image/svg+xml технически “картинка”, но по сути это текстовый XML, который может содержать очень неприятные вещи, если вы когда‑то начнёте отдавать его как есть. Поэтому в учебном проекте логично держать whitelist максимально узким.
Теперь главное: MultipartFile#getContentType() возвращает MIME type, который клиент прислал в заголовках multipart‑части. Это полезный сигнал, но это не доказательство, что файл действительно JPEG/PNG. Клиент может сказать “у меня jpeg”, а прислать что угодно. Поэтому мы используем content type как первый фильтр, а не как единственный способ проверки.
Минимальная whitelist‑проверка может выглядеть так: “разрешены только два типа, иначе ошибка”.
import java.util.Set;
import org.springframework.web.multipart.MultipartFile;
public void validateAvatarType(MultipartFile file) {
// Белый список: разрешаем только то, что мы реально поддерживаем в сценарии "аватар"
Set<String> allowed = Set.of("image/png", "image/jpeg");
// MIME type пришёл от клиента — это сигнал, но не абсолютная истина
String type = file.getContentType();
// Отсекаем неизвестные/подозрительные типы на ранней стадии
if (type == null || !allowed.contains(type)) {
throw new IllegalArgumentException("Unsupported avatar file type");
}
}
Если хочется чуть более аккуратно (и практично для сохранения), можно сразу сопоставить MIME type с расширением файла, которое мы будем использовать на стороне сервера. Это хороший компромисс: мы не доверяем originalFilename, но при этом сохраняем корректное расширение.
import java.util.Map;
import org.springframework.web.multipart.MultipartFile;
public String detectExtension(MultipartFile file) {
// Маппинг "тип -> расширение" определяем на сервере, а не берём из имени файла клиента
Map<String, String> extByType = Map.of(
"image/png", ".png",
"image/jpeg", ".jpg"
);
// Сопоставляем расширение по content-type
String ext = extByType.get(file.getContentType());
// Если тип не поддерживаем — сразу ошибка (и не сохраняем ничего на диск)
if (ext == null) throw new IllegalArgumentException("Unsupported avatar type");
return ext;
}
Если вы заметили, мы всё ещё “верим” content type на слово. В рамках fundamentals‑курса это допустимо, потому что мы держим лимиты маленькими и не строим медиа‑платформу. Но даже тут полезно знать простую проверку “похоже ли это на картинку” с помощью ImageIO.read(...). Она не заменяет антивирус и не является абсолютной защитой, но она резко снижает шанс, что нам подсунут вообще не изображение.
import java.awt.image.BufferedImage;
import java.io.IOException;
import javax.imageio.ImageIO;
import org.springframework.web.multipart.MultipartFile;
public void validateLooksLikeImage(MultipartFile file) throws IOException {
// Пытаемся распарсить изображение: если это не картинка, ImageIO вернёт null
BufferedImage img = ImageIO.read(file.getInputStream());
// Это не "идеальная security-проверка", но хороший фильтр от очевидного мусора
if (img == null) throw new IllegalArgumentException("File is not an image");
}
Это уже усиление, а не обязательный минимум. Для базового avatar-baseline нам достаточно size + whitelist contentType; ImageIO.read() полезен, когда хочется жёстче отсечь очевидный мусор, но отсутствие этой проверки не делает базовую сборку сломанной.
6. originalFilename — ловушка, безопасное имя
Теперь самое любимое: имя файла, которое прислал клиент. Оно называется original filename, и MultipartFile#getOriginalFilename() возвращает его “как есть”. И вот здесь у новичков часто случается ошибка уровня “я ж просто хочу сохранить файл, что может пойти не так”.
Проблема в том, что имя файла — это не просто строка. Если вы превратите его в путь, вы можете случайно открыть дверь в path traversal: когда клиент присылает имя вроде ../../../../etc/passwd или ..\..\..\windows\system.ini. Даже если вы думаете “ну кто так сделает”, не забывайте: кроме пользователей есть автоматические сканеры, боты и люди, которые испытывают системы “на прочность” ради спорта.
Поэтому базовое правило: мы не используем originalFilename как имя на диске. Максимум — можем сохранить его в metadata (и то аккуратно), но физическое имя файла генерируем сами. Для аватара идеально подходит UUID.
import java.util.UUID;
public String generateStoredName(String extension) {
// Имя файла генерирует сервер: это убирает конфликты и снижает риск path traversal
return UUID.randomUUID() + extension; // например: "a3f2...-9c1d.png"
}
Это сразу решает две задачи: не доверяем клиенту и избегаем конфликтов имён (“avatar.png” у всех будет разный). А ещё это уменьшает соблазн сделать «красивый путь с username», который потом начнёт конфликтовать с будущими требованиями безопасности и приватности.
7. Сохранение: resolve/normalize и контроль директории
Мы дошли до самого “железного” места: физическое сохранение файла. И тут у нас есть две цели. Первая цель — сохранить файл в контролируемую директорию, которую задаёт сервер, а не пользователь. Вторая цель — убедиться, что итоговый путь действительно остаётся внутри этой директории (даже если кто-то попытался нас перехитрить строками и слешами).
Сначала: где хранить? В учебном проекте — локально на диске, в отдельной папке, заданной через конфиг, например ./storage/avatars. Почему не в src/main/resources? Потому что в запакованном jar это часть архива, туда нельзя “записать файл”. Если вы попробуете, вы получите боль и философский вопрос “почему же оно работало в IDE”.
Создадим простой сервис хранения, который принимает MultipartFile, генерирует имя и сохраняет.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
public class AvatarStorageService {
private final Path avatarDir;
public AvatarStorageService(@Value("${app.storage.avatar-dir}") Path avatarDir) {
// Приводим путь к абсолютному и нормализуем, чтобы дальше корректно сравнивать startsWith(...)
this.avatarDir = avatarDir.toAbsolutePath().normalize();
}
}
Дальше — метод сохранения. Важно создать директорию, если её нет, и сохранить файл потоком. Для простоты используем Files.copy(...). Это прозрачный и понятный способ: берём InputStream файла и копируем в нужный путь.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.springframework.web.multipart.MultipartFile;
public String save(MultipartFile file, String storedName) throws IOException {
// Гарантируем, что директория существует (иначе copy упадёт)
Files.createDirectories(avatarDir);
// Собираем целевой путь из базовой директории + имени (имя генерирует сервер)
Path target = avatarDir.resolve(storedName).normalize();
// Контроль "мы точно остаёмся внутри avatarDir" — защита от traversal и странных сегментов пути
if (!target.startsWith(avatarDir)) throw new SecurityException("Invalid target path");
// Копируем поток в файл на диске
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
// Возвращаем server-side имя, а не originalFilename
return storedName;
}
Ключевой момент здесь — пара строк с normalize() и startsWith(...). Это простая, но мощная техника: мы говорим “даже если вдруг storedName содержит странные сегменты пути, итоговый путь всё равно обязан остаться внутри avatarDir”. В нашем случае storedName генерируется сервером, так что атака маловероятна, но правило хорошее: оно превращает потенциально хрупкую логику в довольно прочную. Это как ремень безопасности: вы не планируете аварий, но он нужен не для планов.
Даже при UUID-имени это правило лучше не выбрасывать. Иначе финальная сборка endpoint’а легко скатится в более слабый storage-вариант просто потому, что “и так вроде работает”.
Мини-схема: путь файла до диска
Чтобы не потеряться в количестве проверок, полезно держать простую последовательность в голове. Security-слой к этому моменту уже пропустил запрос: access и CSRF считаем пройденными. Дальше нас интересуют проверки файла и путь до диска.
Ниже схематично показано, где находятся “точки контроля” именно для этой лекции:
flowchart TD
A[Запрос POST multipart] --> B[Multipart parsing в Spring MVC]
B --> C[Контроллер получил MultipartFile]
C --> D[Проверка: пустой ли файл]
D --> E[Проверка: размер]
E --> F[Проверка: MIME type / looks like image]
F --> G[Генерация безопасного имени UUID]
G --> H[resolve + normalize + startsWith]
H --> I[Сохранение Files.copy]
I --> J[Возвращаем storedName в application layer]
8. Типичные ошибки при проверке и сохранении аватара
Ошибка №1: надеяться только на SecurityFilterChain и забыть про сам файл.
Очень часто новичок аккуратно закрывает /api/me/avatar от anonymous‑доступа и даже правильно проходит CSRF, а потом принимает и сохраняет “что угодно”. В результате endpoint формально защищён, но практически превращается в дырку для мусора, больших файлов и неожиданных форматов. Правильная мысль здесь простая: доступ проверяет “кто”, валидация файла проверяет “что”.
Ошибка №2: проверять размер только настройками Spring Boot и не иметь прикладного лимита.
Да, spring.servlet.multipart.max-file-size спасает от очень больших запросов, но он не заменяет ваше правило “какой аватар мы считаем нормальным”. У вас должен быть явный лимит в коде (и желательно рядом с названием MAX_AVATAR_BYTES), иначе через месяц никто не вспомнит, почему 2MB — это много или мало, и почему ошибка приходит “не оттуда”.
Ошибка №3: доверять getContentType() как единственной проверке типа.
contentType — это то, что сказал клиент. Это полезно как первое сито, но не как приговор. Если вы хотите минимально усилить проверку в учебном проекте, добавьте “looks like image” через ImageIO.read(...). Это не превращает вас в security‑лабораторию, но делает поведение заметно устойчивее к очевидному мусору.
Ошибка №4: использовать originalFilename как имя файла на диске.
Это одна из самых опасных привычек. Она приводит к конфликтам имён (“у всех avatar.png”), к риску path traversal, к смешению “красивого UX” с файловой безопасностью. Самое спокойное решение — сервер генерирует имя (UUID), а клиентское имя остаётся просто “подписью”, и то не всегда нужной.
Ошибка №5: сохранять файл в непонятное место и потом удивляться, что в jar ничего не работает.
Когда вы запускаете приложение из IDE, вам кажется, что “resources рядом, туда и положим”. Но как только приложение упаковано, папка ресурсов превращается в часть архива. Правильный подход — хранить аватары в отдельной директории, заданной через application.yml, например ./storage/avatars, и создавать её через Files.createDirectories(...).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ