1. Hardening: цель и поверхности атаки
Когда проект уже «в целом защищён», появляется очень человеческое желание выдохнуть и сказать: «Ну всё, у нас же есть роли, JWT/сессия, @PreAuthorize, тесты — безопасность сделана». Hardening нужен именно в этот момент: он не добавляет магии, он убирает мелкие щели, через которые обычно и утекают самые неприятные баги.
Важная ментальная модель: у приложения есть поверхности (attack surface). Они не равны «всем endpoint’ам». Некоторые поверхности прямо кричат: «Я удобная штука для разработчика/оператора, но если меня увидит внешний мир — будет грустно». Типичные примеры: Swagger, Actuator, admin endpoints и file upload.
Ниже — небольшая карта поверхностей нашего Secure Content Platform API (не как список “делай так”, а как способ смотреть на приложение):
| Поверхность | Примеры путей | Почему чувствительно | Базовая идея hardening |
|---|---|---|---|
| Docs / API description | /swagger-ui/** | Это карта вашего API и контрактов | Явно закрываем (обычно ADMIN) |
| Docs / API description | /v3/api-docs/** | Это карта вашего API и контрактов | Явно закрываем (обычно ADMIN) |
| Admin | /api/admin/** | Высокий ущерб, если доступ откроется случайно | Явные matchers + method security |
| Upload | POST /api/me/avatar | Бинарные данные, размер, тип, хранение | Ограничения + ownership + CSRF нюансы |
| Ops / Actuator | /actuator/** | Может отдавать чувствительную информацию | Минимальная экспозиция + отдельные правила |
И ещё одна важная мысль для hardening: вы можете быть уверены в логике ролей, но ошибиться в границах. Например, открыть Swagger «на пять минут», забыть закрыть, а потом обнаружить его через два месяца… но уже не вы.
Когда transport уже не врёт про схему запроса и приложение понимает свой внешний контур, следующая проблема становится очень приземлённой: какие поверхности вообще стоит торчать наружу. Теперь сузим attack surface и посмотрим на зоны, которые чаще всего оставляют открытыми «временно».
2. Документация API: удобно, но не public endpoint
Swagger и OpenAPI-документация — это потрясающе удобная вещь… для команды разработки. Но для атакующего это тоже удобная вещь: готовая карта эндпоинтов, DTO, примеры запросов, иногда даже подсказки по security-схемам. Поэтому hardening docs — это не «паранойя», а просто взрослая дисциплина.
Что мы защищаем на практике
На практике чаще всего появляются два набора URL:
- /swagger-ui/** — UI страница (в браузере).
- /v3/api-docs/** — JSON со схемой.
Мы не будем уходить в тему подключения springdoc и прочих библиотек (это не курс про документацию), но принцип hardening от этого не меняется: если путь существует — у него должно быть явное правило доступа.
Мини-пример: закрываем docs под ADMIN
Ниже — маленький фрагмент, который логически «доклеивается» в ваш существующий SecurityFilterChain. Обратите внимание на идею: docs не “случайно закрылись потому что .anyRequest().authenticated()”, а закрыты явно и читаемо — это важно для аудита.
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Явно закрываем docs: чтобы в конфиге было видно намерение, а не «вроде само закрылось».
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**").hasRole("ADMIN")
// Остальные правила (API, статика и т.д.) предполагаются ниже в вашей реальной конфигурации.
);
return http.build();
}
Этот подход хорошо сочетается с нашей общей идеей «минимальная поверхность»: docs — не обязательная часть продукта для анонимного мира, поэтому мы не стесняемся закрывать её строго.
Нюанс про CORS и docs
Иногда хочется “разрешить фронту читать OpenAPI схему по CORS”. В проде это почти всегда плохой знак: docs-эндпоинты не должны быть «обычным API для браузера». В hardening-мышлении operational/docs поверхности — отдельные зоны, и если вы вдруг включаете для них CORS, это должно быть очень осознанным решением, а не копипастой из общей CORS-конфигурации.
3. Admin-зона: явные правила и минимум поверхности
Admin endpoints в проекте — это те самые кнопки «переключить роли», «заблокировать аккаунт» и «выключить пользователя». Разница с обычным API здесь простая: ошибка в доступе не просто “покажет лишний черновик”, а может поменять состояние всей системы. Поэтому hardening admin-зоны — это дисциплина «двойных ремней безопасности».
Первый ремень — request-level правила в SecurityFilterChain. Второй ремень — method security в сервисном слое. Мы не будем повторять method security (это было раньше), но важно помнить, что hardening — это проверка согласованности: “кнопка закрыта” и “действие закрыто” должны совпадать.
Мини-пример: outer gate по роли, user-management — по authority
В курсе мы используем и роли, и authorities. Внешняя граница admin-зоны вполне может оставаться role-based (ADMIN), а user:manage — это уже более узкое business permission для операций управления пользователями внутри неё. Так видно, что это не новая альтернативная модель всей админки, а следующая степень точности там, где цена ошибки выше.
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Самые чувствительные user-management endpoint'ы можно закрыть по явной business authority.
// Предполагаем, что `user:manage` живёт внутри admin-модели, а не раздаётся "кому угодно".
.requestMatchers("/api/admin/users/**").hasAuthority("user:manage")
// Остальная admin-зона остаётся за более широким role-based правилом.
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Важно: «широкие» правила (типа "/api/**") обычно ставят ниже, чтобы не затереть это.
);
return http.build();
}
Это не означает, что роль ADMIN “плохая”. Просто на уровне outer gate она отделяет admin-зону от остального мира, а authority вроде user:manage делает конкретное действие читаемым для аудита, тестов и service-level защиты.
Hardening-нюанс: порядок и “широкие” matcher’ы
Admin-зона часто страдает от “широких” правил выше по конфигурации. Например, кто-то поставил .requestMatchers("/api/**").authenticated() и ниже добавил admin-правило — и затем удивляется, почему админка стала доступна любому залогиненному пользователю. В следующей лекции у нас будет troubleshooting, но уже сейчас держите в голове hardening-мысль: “широкие правила должны быть ниже, узкие — выше”.
Нюанс: URL-манипуляции и firewall
Ещё один аспект “минимальной поверхности” — корректные matchers и защита от странных URL. Внутри FilterChainProxy Spring Security использует HttpFirewall и по умолчанию отклоняет не-нормализованные запросы (вроде path-traversal последовательностей /../, дублей //, параметров в пути), чтобы pattern-matching работал предсказуемо.
Hardening здесь простой: не начинайте «ослаблять firewall», пока не понимаете, какую дыру вы открываете. Иначе вы легко превращаете “просто неудобный кейс” в обход правил.
4. File upload: лимиты, CSRF, хранение
Файл — это не JSON. У JSON обычно есть понятный размер, понятная структура, валидатор и стандартные ошибки. Файл же может быть огромным, странным, бинарным, и ещё он очень любит, когда его сохраняют “куда попало и как попало”. Поэтому upload — классический кандидат на hardening, даже если доступ к endpoint’у уже защищён.
В нашем проекте upload — это POST /api/me/avatar. Он owner-based (только “я сам”), и уже поэтому кажется безопасным. Но hardening напоминает: безопасность upload’а — это не только «кто может вызвать endpoint», но и «что именно можно загрузить» и «что мы с этим делаем».
Ограничение размера: multipart limits в Spring Boot
Если вы никогда не ставили лимиты, то у вашего приложения есть скрытая фича: “любой может попытаться залить вам диск”. И это точно не та “фича”, которой стоит гордиться на собеседовании. Хорошая новость: Spring Boot уже по умолчанию имеет ограничения на multipart upload и позволяет их удобно переопределять.
По документации Spring Boot для Spring MVC есть дефолты: 1MB на файл и 10MB на все файлы в одном запросе, плюс набор свойств для переопределения и настройки места временного хранения. Hardening-здоровая позиция такая: лимит должен быть явным и соответствовать бизнес-кейсу. Для аватара 2MB обычно более чем достаточно (если у вас аватар весит 40MB — это уже не аватар, это заявление о жизненной позиции).
Мини-конфиг:
spring:
servlet:
multipart:
# Максимальный размер одного файла (аватар не должен «пылесосить» диск/память сервера).
max-file-size: 2MB
# Общий размер запроса (в нашем кейсе он равен размеру файла).
max-request-size: 2MB
Эта настройка не решит все проблемы upload’а, но она закрывает самый базовый класс рисков: неконтролируемый размер.
Ограничение типа и имени: whitelist > доверия расширению
Почти каждый новичок однажды наступает на грабли “проверю расширение файла”. Потом реальность говорит: «переименуй мне .exe в .png — и я посмотрю, что ты там проверяешь». В hardening-мышлении расширение — это максимум hint, но не критерий допуска.
Самый простой и учебно понятный подход — whitelist по Content-Type (а в реальном мире ещё и проверка сигнатуры файла, но это уже за границами fundamentals).
import java.util.Set;
// Разрешаем только ожидаемые форматы (white-list), всё остальное — сразу отклоняем.
Set<String> allowed = Set.of("image/png", "image/jpeg");
// contentType берём из multipart metadata (важно помнить: его можно подделать, но это хорошая базовая фильтрация).
if (!allowed.contains(contentType)) {
// Сообщение можно сделать более «API-friendly», но сама идея hardening — не принимать всё подряд.
throw new IllegalArgumentException("Unsupported avatar type");
}
И да, это по-прежнему не идеальная защита (потому что Content-Type можно подделать), но как hardening-база для курса — это честный, понятный шаг: “мы не принимаем всё подряд”.
CSRF + multipart: “курица и яйцо” и почему header часто лучше
Дальше начинается то, что в документации Spring Security называют “chicken and egg problem”. Чтобы защититься от CSRF, серверу нужно прочитать тело запроса, чтобы достать CSRF token. Но чтение тела multipart-запроса означает, что файл уже начал загружаться, и внешняя страница потенциально может “залить вам временные файлы”, даже если пользователь не авторизован.
Spring Security прямо описывает эту проблему и варианты. Если у вас есть JavaScript-клиент (а для современного API это обычно так), то рекомендуемый путь — передавать CSRF token в HTTP header для multipart. Это позволяет не класть токен в URL и не городить нестабильные схемы.
Если токен кладут в body, то ради чтения токена multipart может быть обработан до того, как выполнится авторизация, и тогда “любой может разместить временный файл на сервере”, хотя обработка приложения всё равно останется доступной только авторизованному пользователю.
Hardening-вывод здесь такой: даже если вы уже правильно настроили CSRF в session-ветке, multipart требует отдельного внимания, и “просто работает” — не всегда означает “безопасно и предсказуемо”.
Хранение: где лежит файл, как назвать и что записать в БД
Последний кусок hardening для upload’а — это хранение. Типичный анти-паттерн выглядит так: взять originalFilename и сохранить “как есть” в каталог, который кто-то когда-то настроил в конфиге. Если повезёт, это просто сломается. Если не повезёт — это станет историей про path traversal и сюрпризы.
В учебном проекте достаточно простой дисциплины: генерируем безопасное имя (например, UUID), исходное имя сохраняем только как metadata, а путь строим через Path.resolve, а не через конкатенацию строк.
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
// Генерируем безопасное имя: не используем originalFilename для имени файла на диске.
Path target = avatarDir.resolve(UUID.randomUUID() + ".bin");
// Копируем поток в целевое место.
// В реальном коде важно дополнительно обрабатывать: уже существующий файл, права, атомарность, лимиты и т.д.
Files.copy(multipartFile.getInputStream(), target); // сохраняем безопасно
И дальше вы связываете target с владельцем (ownerUserId) в своей модели StoredFile / UserProfile. В нашем курсе это важнее, чем “красиво сохранить картинку”: hardening — это про контроль и предсказуемость поведения.
5. Actuator: экспозиция и отдельный контур
Actuator — штука очень полезная: health, info, метрики, состояние приложения. Но полезная она не только вам. Если “случайно” открыть слишком много actuator endpoint’ов наружу, вы можете подарить внешнему миру лишнюю информацию про приложение, окружение и конфигурацию. Поэтому hardening тут начинается не с “как закрыть”, а с “что вообще открываем”.
Spring Boot в документации прямо подчёркивает, что endpoints могут содержать чувствительные данные и их экспозицию нужно продумывать, а по умолчанию over HTTP показывается путь вида /actuator/{id} и health соответствует /actuator/health.
Экспозиция по минимуму: что вообще открываем
Первый шаг hardening — ограничить экспозицию. В Boot 4 по умолчанию по HTTP и JMX экспонируется только health, а список exposed endpoints управляется настройками management.endpoints.*.exposure.include/exclude.
Hardening-модель тут простая: если вы не уверены, что endpoint безопасен для внешнего мира, он не должен быть exposed.
Минимальный и довольно типичный конфиг для учебного проекта:
management:
endpoints:
web:
exposure:
# Явно перечисляем, что отдаём по HTTP (всё остальное — не экспонируется).
include: "health,info"
Это не означает, что info всегда можно открывать публично. Это означает, что вы выбираете маленький набор и дальше решаете доступ через security rules.
Custom SecurityFilterChain и Actuator: почему Boot “отступает”
Очень важный нюанс, который часто ломает людям голову на реальных проектах: если Spring Security на classpath и нет вашего SecurityFilterChain, то Boot auto-configuration умеет “в целом правильно” прикрыть actuator endpoints (в частности, не оставлять всё открытым). Но как только вы определяете свой SecurityFilterChain, Boot auto-config back off, и правила доступа к actuator становятся полностью вашей ответственностью.
Это hardening-поворот мысли: когда вы становитесь “взрослым” и пишете свой security chain, вы автоматически берёте ответственность и за операционные endpoint’ы. Оно справедливо: если вы делаете явную конфигурацию, значит вы хотите контролировать поведение сами.
Отдельная цепочка для actuator и опасность “дырки” без default chain
Практически удобный подход — выделить actuator в отдельный SecurityFilterChain через securityMatcher, используя EndpointRequest (Spring Boot даёт готовые matcher’ы для actuator).
Но тут есть классическая ловушка: securityMatcher ограничивает цепочку, и если для какого-то запроса не нашлось цепочки, то запрос не защищён Spring Security вообще. Документация прямо об этом предупреждает: “If no filter chain matches a particular request, the request is not protected”.
Поэтому hardening-правило такое: делая отдельную цепочку для actuator, вы обязаны иметь и “основную” цепочку (default chain), которая покрывает остальные запросы.
Мини-пример отдельной цепочки для actuator (показываю с httpBasic, потому что это часто удобно для ops, но вы можете выбрать другой механизм, если он уже есть):
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.SecurityFilterChain;
@Order(1)
@Bean
SecurityFilterChain actuatorChain(org.springframework.security.config.annotation.web.builders.HttpSecurity http)
throws Exception {
return http
// Эта цепочка применяется только к actuator endpoints (не ко всему приложению).
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
// Минимально: только ADMIN (или отдельная ops-роль, если у вас она есть).
.anyRequest().hasRole("ADMIN")
)
// Для ops часто удобно иметь простой механизм (например, для Prometheus/healthchecks под защитой).
.httpBasic(Customizer.withDefaults())
.build();
}
Ключевая идея: actuator-правила не смешиваются с обычным API, и вы не рискуете случайно открыть /actuator/** через какой-нибудь широкий matcher.
6. Типичные ошибки при hardening docs/admin/upload/actuator
Ошибка №1: оставить Swagger открытым “на время разработки”.
Это один из самых частых случаев. В моменте кажется, что так удобно: тестировать руками, показывать контракт, быстро проверять DTO. Но потом “время разработки” превращается в полгода, а docs становятся публичной картой вашего API. Hardening требует, чтобы docs имели явное правило доступа и, желательно, зависели от профиля, а не от памяти команды.
Ошибка №2: перепутать «exposure actuator endpoints» и «доступ к actuator endpoints».
Многие думают, что достаточно management.endpoints.web.exposure.include, и вопрос решён. Но это только “какие endpoints существуют по HTTP”. Доступ к ним при наличии кастомного SecurityFilterChain — ваша ответственность. Spring Boot явно описывает, что при кастомной цепочке auto-config отступает, и правила задаёте вы.
Ошибка №3: сделать отдельный SecurityFilterChain для actuator и забыть про default chain.
Это особенно коварно, потому что в голове кажется: “я же всё покрыл”. Но если какая-то цепочка не матчится — запрос не защищён. Документация прямо говорит: если ни один filter chain не подходит, запрос не будет защищён. Hardening здесь — всегда держать “главную” цепочку, которая закрывает остальные запросы.
Ошибка №4: считать upload безопасным, потому что он owner-only.
Owner-only защищает от чужих пользователей, но не защищает от “плохих файлов”. Огромный файл, неподдерживаемый тип, странное имя, небезопасный путь хранения — всё это остаётся проблемой даже для владельца. Поэтому hardening upload включает лимиты и проверки типа, а Spring Boot прямо описывает наличие дефолтных лимитов и возможность их переопределять.
Ошибка №5: игнорировать CSRF нюансы multipart и чинить всё csrf().disable().
Multipart + CSRF — реально особый случай. Spring Security описывает “курица и яйцо” проблему: для CSRF нужно прочитать тело, но чтение тела означает загрузку файла, поэтому есть trade-offs, и для JS-клиентов предпочтителен токен в header. Hardening — это понять компромисс и выбрать стратегию, а не выключить механизм “потому что мешает загрузить аватар”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ