1. Upload как изменение состояния, а не обмен данными
Когда разработчик впервые слышит “нужно сделать загрузку аватара”, мозг часто рисует картинку уровня: “ну это как PATCH, только вместо JSON — файл”. Это нормальная реакция, пока не начинаешь думать, что именно происходит в системе. Загрузка файла почти всегда меняет состояние приложения, а значит автоматически становится security‑сценарием, а не просто “ещё одной функциональностью”.
В нашем проекте аватар — это часть профиля пользователя. То есть когда мы принимаем файл, мы не просто принимаем байты “в пустоту”. Мы делаем две вещи: сохраняем файл (или его представление) и меняем ссылку на аватар у конкретного пользователя. Это уже похоже не на “передать данные”, а на “внести изменения в учётную запись”, а это всегда зона повышенного внимания.
Мини-пример, который хорошо “приземляет” мысль: профиль хранит путь/ссылку на аватар, а не сам файл.
package com.example.securecontent.profile;
public class UserProfile {
private String displayName;
// Важно: это часть состояния профиля, а не "служебная мелочь"
private String avatarPath;
// getters/setters ...
}
Обратите внимание на простую, но важную вещь: поле avatarPath — это часть состояния пользователя. Значит, POST /api/me/avatar — это не “передача файла”, а “операция, которая меняет профиль”. Поэтому мы должны думать про безопасность как минимум на уровне “кто имеет право это сделать”, “можно ли подделать запрос”, “что именно можно прислать” и “что мы сделаем с тем, что нам прислали”.
2. Слои проверок для upload‑endpoint’а
Когда у нас обычный JSON‑endpoint вроде PATCH /api/me/profile, мы уже привыкли: есть входные данные, есть validation, есть security‑правила. Но upload — это ситуация, где слишком легко ошибиться, если думать только в стиле “ну давайте сделаем authenticated() и всё”. На самом деле у такого endpoint’а почти всегда есть сразу несколько слоёв проверок, и они отвечают на разные вопросы.
Я люблю мыслить upload‑сценарием как “коридором с несколькими дверями”. Первая дверь — это доступ (аутентификация/авторизация), вторая — это корректность содержимого, третья — безопасное сохранение результата. Если вы поставили охранника только у первой двери, но дальше оставили открытый склад с чем угодно — поздравляю, у вас охраняемый вход в бардак.
Нагляднее всего это показать таблицей. Это не “официальный стандарт”, а удобная инженерная карта:
| Слой проверки | Вопрос, на который отвечаем | Где это должно жить | Пример сбоя |
|---|---|---|---|
| Доступ к endpoint’у | “Кто вообще имеет право вызывать /api/me/avatar?” | SecurityFilterChain и уже известные правила /api/me/** | пользователь не вошёл в систему |
| Защита state-changing запроса | “Запрос не подделан в браузерной/cookie модели?” | CSRF‑механика Spring Security | нет/неверный CSRF token |
| Проверка файла | “Что именно нам прислали и подходит ли это под аватар?” | прикладная логика сервиса (и немного config) | файл пустой, огромный, не изображение |
| Безопасное сохранение | “Как сохранить так, чтобы не получить дыру в файловой системе?” | storage‑слой проекта | path traversal, перезапись чужого файла |
Здесь важнее всего зафиксировать саму мысль: upload — это сценарий, где “проверить доступ” — необходимо, но недостаточно. У каждого слоя свой вопрос и своя точка поломки.
3. Личная зона: /api/me/avatar вместо /api/users/{id}/avatar
Если вы делали backend раньше, у вас может возникнуть желание “обобщить” endpoint и написать что-то вроде POST /api/users/{id}/avatar. Выглядит красиво: передали id, загрузили файл — готово. Проблема в том, что с точки зрения безопасности вы внезапно сами себе создаёте мину под ноги: кто мешает обычному пользователю подставить чужой id?
Паттерн “endpoint принимает идентификатор объекта, а авторизация в коде забыта или сделана криво” — это один из самых частых источников broken access control. И он неприятен тем, что приложение может “вроде работать”, а уязвимость будет жить годами, пока кто-то не догадается проверить “а что если подставить другой id”.
В нашем проекте мы изначально выбрали endpoint в стиле “owner-by-design”: /api/me/avatar. Он не принимает userId. Он по смыслу говорит: “я меняю аватар текущего пользователя”. И это очень правильное решение для учебного проекта: оно не отменяет необходимость проверки аутентификации, но резко снижает шанс сделать уязвимость уровня “пользователь меняет аватар соседу”.
Чтобы почувствовать разницу, сравним два контроллера. Первый — потенциально опасный (пример “как делать не надо”):
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
public class InsecureAvatarController {
@PostMapping("/api/users/{id}/avatar")
public void upload(@PathVariable long id) {
// Риск: если забыть/ошибиться в проверке, получится IDOR (смена аватара чужому пользователю)
// "Ой, а где проверка, что id == current user?"
}
}
Второй — “owner-by-design”, в котором у запроса нет “рычага” в виде чужого id:
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
public class MeAvatarController {
@PostMapping("/api/me/avatar")
public void upload() {
// Здесь по дизайну нет userId: меняем аватар только текущего пользователя
}
}
Заметьте, мы ещё даже не обсуждали multipart, MultipartFile, типы и размеры. Мы пока просто сделали важный security‑дизайн выбор: endpoint не должен случайно превращаться в “универсальную пушку”, которая стреляет по любым пользователям при ошибке в авторизации.
4. Риски multipart‑загрузки вместо JSON
До этого момента мы смотрели на upload как на security‑сценарий. Теперь важно увидеть технический симптом: как только в запросе появляется файл, это уже не привычный @RequestBody с JSON, а multipart/form-data.
Multipart — другой зверь. Он может быть большим, он несёт бинарные данные, он может вести себя по‑разному в разных клиентах, и у него есть “физическая” цена в ресурсах: память, диск, время обработки. Поэтому даже корректно аутентифицированный пользователь всё ещё может создать вам проблему просто размером или содержимым файла.
И сигнатура endpoint’а начинает выглядеть иначе:
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
public class AvatarController {
// consumes важен: этим мы явно говорим "я принимаю multipart", а не JSON
@PostMapping(path = "/api/me/avatar", consumes = "multipart/form-data")
public void uploadAvatar(@RequestParam("file") MultipartFile file) {
// file — это пользовательский ввод: его обязательно нужно будет валидировать по размеру/типу/содержимому
// дальше будет сервис, проверки, сохранение...
}
}
Пока здесь достаточно схватить две вещи: endpoint явно говорит consumes = "multipart/form-data", а файл приходит не как DTO, а как MultipartFile. Именно здесь обычно и всплывает следующий инженерный вопрос: как multipart‑запрос доезжает до контроллера и в какой момент Spring превращает его в MultipartFile.
5. Классы ошибок upload‑endpoint’а
В security‑теме есть неприятная привычка: когда что-то не работает, разработчик смотрит на ошибку и говорит “ну, Spring Security опять мешает”. А потом делает .csrf(csrf -> csrf.disable()) и на минуту чувствует себя победителем. Дальше, правда, обычно приходит реальность и говорит: “а теперь объясни, почему мы отключили защиту от подделки запросов”.
В upload‑сценарии особенно важно заранее понимать, что “не получилось загрузить аватар” — это вообще не одна ошибка. Это минимум четыре разных класса проблем, которые требуют разной реакции и разного уровня “где чинить”.
Можно представить это как небольшой “дерево решений”:
flowchart TD
A["POST /api/me/avatar"] --> B{"Пользователь вошёл?"}
B -- нет --> C[login flow / доступ закрыт]
B -- да --> D{"CSRF token корректный?"}
D -- нет --> E[CSRF failure]
D -- да --> F{"Файл корректный?"}
F -- нет --> G[Ошибка файла: пустой/тип/размер]
F -- да --> H[Сохранить файл и обновить профиль]
Обратите внимание: только последняя ветка — это “настоящая бизнес-операция”. Всё до этого — безопасность и прикладная валидация на границе.
В browser/session модели “симптомы” могут выглядеть по‑разному: в браузере вы можете увидеть редирект на логин-страницу, а в Postman — один статус, а в логах — ещё что-то. Это нормально, мы уже видели, что разные клиенты по‑разному проживают одну и ту же security‑модель.
Ключевая мысль: мы не должны смешивать в голове “ошибка доступа”, “ошибка CSRF” и “ошибка файла”. Если вы смешали — вы почти наверняка начнёте лечить не ту болезнь. Upload — отличный тренажёр для этого навыка, потому что здесь ошибки встречаются часто и быстро.
6. Архитектурный каркас в проекте
Сейчас полезно увидеть не окончательный runnable‑вариант, а только каркас. У upload‑сценария здесь три опоры: личная зона /api/me/**, тонкий контроллер на multipart/form-data и сервис, в котором потом сходятся проверки файла, безопасное сохранение и обновление профиля. Если пытаться зафиксировать всё сразу, каркас моментально зарастёт деталями и превратится во второй, случайно более слабый вариант того же endpoint’а.
На уровне access rules нам сейчас нужна только одна мысль: аватар живёт в личной зоне, значит endpoint наследует правило “только для аутентифицированного пользователя”.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/me/**").authenticated()
.anyRequest().denyAll()
);
Служебные точки вроде получения CSRF token или детали login flow здесь пока специально не раскладываем: нам важен именно контур личной зоны.
Контроллер тоже держим в форме скелета. Он явно принимает multipart/form-data, ждёт part с именем file и сразу делегирует сценарий дальше.
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
public class AvatarController {
private final AvatarService avatarService;
public AvatarController(AvatarService avatarService) {
this.avatarService = avatarService;
}
@PostMapping(path = "/api/me/avatar", consumes = "multipart/form-data")
public void uploadAvatar(@RequestParam("file") MultipartFile file) {
// Контроллер только принимает входные данные и передаёт сценарий дальше
avatarService.upload(file);
}
}
А сервис пока можно воспринимать как центр сценария, а не как готовую реализацию.
import org.springframework.web.multipart.MultipartFile;
public class AvatarService {
public void upload(MultipartFile file) {
// Здесь позже сходятся:
// 1) current user
// 2) проверки размера и типа
// 3) безопасное сохранение
// 4) обновление avatarPath в профиле
}
}
Этого каркаса уже достаточно, чтобы не потерять логику: endpoint живёт в личной зоне, контроллер тонкий, основная работа уходит в сервис, а проверка файла и storage — это отдельные слои, а не случайные if‑ы в одном методе.
7. Типичные ошибки при загрузке аватара
Когда вы первый раз делаете file upload, очень легко попасть в ловушку “я же уже знаю security: поставлю authenticated() и готово”. На практике это приводит к хрупким решениям, которые либо открывают дыру, либо вынуждают вас отключать защитные механизмы, потому что “мешают”. Ниже — самые частые ошибки именно на уровне сценария и дизайна, ещё до того, как мы углубимся в детали multipart.
Ошибка №1: считать upload “обычной фичей”, а не изменением состояния.
Если относиться к загрузке аватара как к “просто передаче файла”, легко забыть, что вы меняете профиль пользователя и создаёте новый артефакт в хранилище. Потом внезапно оказывается, что любой вошедший пользователь может “залить что угодно”, а система даже не понимает, что она хранит и зачем. Правильная опора тут простая: аватар — часть профиля, значит это state-changing операция и должна быть защищена как изменение профиля.
Ошибка №2: проектировать endpoint как /api/users/{id}/avatar без жёсткой owner‑логики.
Такой дизайн не всегда неправильный, но он требует очень аккуратной авторизации: нужно гарантировать, что пользователь может менять только свой аватар (или что админ может менять чужой — если это явно задумано). Если вы пока не готовы выражать такие правила, безопаснее и проще держать endpoint в стиле /api/me/avatar: меньше шансов сделать “IDOR на ровном месте”.
Ошибка №3: смешивать причины ошибок в один “не загрузилось”.
Если в голове нет разделения “не вошёл”, “CSRF не прошёл”, “файл плохой”, “storage не смог сохранить”, вы будете чинить не там. Например, отключите CSRF, хотя у вас просто файл пустой. Или начнёте ковырять multipart, хотя вы не аутентифицированы. В upload‑сценарии полезно прямо заранее держать эти причины как разные ветки.
Ошибка №4: делать контроллер “богом” сценария.
Когда в одном методе контроллера и парсинг, и проверка, и сохранение, и изменение профиля, и обработка ошибок — код становится трудным для понимания и сопровождения. А в security‑коде “трудно поддерживать” почти всегда означает “однажды кто-то случайно откроет доступ”. Гораздо безопаснее дисциплина: контроллер тонкий, сервисы выполняют шаги, а security‑инфраструктура делает своё до входа в бизнес‑код.
Ошибка №5: думать, что раз пользователь вошёл, то файл автоматически безопасный.
Аутентификация отвечает на вопрос “кто ты?”, но никак не отвечает на вопрос “что ты прислал?”. Даже добропорядочный пользователь может случайно загрузить огромный файл или файл не того типа. А недобропорядочный — сделает это специально. Поэтому у upload‑сценария всегда должен быть “второй слой” защиты: правила по типу и размеру, а также аккуратное сохранение.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ