JavaRush /Курсы /Spring Security /Проверка файла и безопасное сохранение

Проверка файла и безопасное сохранение

Spring Security
13 уровень , 3 лекция
Открыта

1. Доступ ≠ безопасный файл

Если вы только что поймали себя на мысли «ну раз пользователь аутентифицирован, то он же наш, значит можно принимать его файл», поздравляю: вы мыслите как оптимистичный человек, который верит людям… а потом читает логи в 3 часа ночи. В upload-сценариях аутентификация и CSRF отвечают лишь на вопрос «кто делает запрос и имеет ли он право менять состояние», но они не отвечают на вопрос «что именно он прислал и сколько это будет стоить системе».

В аватарном кейсе у нас типичная “тройная защита”: доступ к endpoint’у, корректность CSRF и корректность самого файла. Эта лекция как раз про третий слой. Мы хотим, чтобы сервер не принимал «аватар» размером 200MB, не сохранял файл с именем ../../application.yml, и не позволял пользователю случайно (или “случайно”) устроить мини‑DoS на диске и памяти.

Два уровня ограничений: платформа и сценарий

В мире Spring Boot очень легко перепутать две похожие вещи: “лимиты multipart” и “наши бизнес‑правила для аватара”. Они обе про размер, обе про ограничения, обе «где-то в конфиге», но смысл разный. Multipart‑лимиты — это защита уровня платформы: они не дают вашему приложению вообще начать нормально обрабатывать слишком большой запрос. А прикладная валидация — это защита уровня сценария: она задаёт правила именно для аватара как сущности домена.

Удобно держать эту разницу в голове как маленькую таблицу. Она помогает не спорить с самим собой и не ловить странные баги, когда “у меня лимит 2MB в коде, но всё равно падает на 1MB”.

Уровень Где задаём Что защищает Типичный эффект
Multipart limit application.ymlspring.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(...).

1
Задача
Spring Security, 13 уровень, 3 лекция
Недоступна
Валидация типа и размера аватара
Валидация типа и размера аватара
1
Задача
Spring Security, 13 уровень, 3 лекция
Недоступна
Безопасное сохранение под серверным именем
Безопасное сохранение под серверным именем
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ