1. Hardening: мета і поверхні атаки
Коли проєкт уже «загалом захищений», з’являється дуже людське бажання видихнути й сказати: «Ну все, у нас же є ролі, JWT/сесія, @PreAuthorize, тести — безпеку зроблено». Hardening потрібен саме в цей момент: він не додає магії, а прибирає дрібні щілини, через які зазвичай і витікають найнеприємніші баги.
Важлива ментальна модель: у застосунку є поверхні атаки (attack surface). Вони не збігаються з «усіма кінцевими точками». Деякі поверхні прямо кричать: «Я зручна штука для розробника чи оператора, але якщо мене побачить зовнішній світ — буде сумно». Типові приклади: Swagger, Actuator, адмін-кінцеві точки та file upload.
Нижче — невелика карта поверхонь нашого Secure Content Platform API (не як список «робіть так», а як спосіб дивитися на застосунок):
| Поверхня | Приклади шляхів | Чому це чутливо | Базова ідея hardening |
|---|---|---|---|
| Документація / опис API | /swagger-ui/** | Це карта вашого API та контрактів | Явно закриваємо (зазвичай ADMIN) |
| Документація / опис API | /v3/api-docs/** | Це карта вашого API та контрактів | Явно закриваємо (зазвичай ADMIN) |
| Адмін-зона | /api/admin/** | Високі ризики, якщо доступ випадково відкриється | Явні matcher’и + method security |
| Завантаження | POST /api/me/avatar | Бінарні дані, розмір, тип, зберігання | Обмеження + ownership + нюанси CSRF |
| Ops / Actuator | /actuator/** | Може повертати чутливу інформацію | Мінімальна експозиція + окремі правила |
І ще одна важлива думка для hardening: ви можете бути впевнені в логіці ролей, але помилитися в межах. Наприклад, відкрити Swagger «на п’ять хвилин», забути закрити, а потім виявити його через два місяці — але вже не ви.
Коли транспорт уже не вводить в оману щодо схеми запиту і застосунок розуміє свій зовнішній контур, наступна проблема стає дуже приземленою: які поверхні взагалі варто виставляти назовні. Тепер звузимо attack surface й подивімося на зони, які найчастіше залишають відкритими «тимчасово».
2. Документація API: зручно, але не public endpoint
Swagger і OpenAPI-документація — це неймовірно зручна річ… для команди розробки. Але для атакувальника це теж зручна річ: готова карта кінцевих точок, DTO, приклади запитів, іноді навіть підказки щодо security-схем. Тому hardening документації — це не «параноя», а просто доросла дисципліна.
Що ми захищаємо на практиці
На практиці найчастіше з’являються два типові набори 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 endpoints у проєкті — це ті самі кнопки «перемкнути ролі», «заблокувати обліковий запис» і «вимкнути користувача». Різниця зі звичайним API тут проста: помилка в доступі не просто «покаже зайвий чорновик», а може змінити стан усієї системи. Тому hardening адмін-зони — це дисципліна «подвійних ременів безпеки».
Перший ремінь — правила на рівні запиту в SecurityFilterChain. Другий ремінь — method security у сервісному шарі. Ми не будемо повторювати method security (це було раніше), але важливо пам’ятати, що hardening — це перевірка узгодженості: «кнопка закрита» і «дія закрита» мають збігатися.
Мініприклад: outer gate за роллю, user-management — за authority
У курсі ми використовуємо і ролі, і authorities. Зовнішній рубіж адмін-зони цілком може лишатися рольовим (ADMIN), а user:manage — це вже вужче бізнес-повноваження для операцій керування користувачами всередині неї. Так видно, що це не нова альтернативна модель усієї адмінки, а наступний ступінь точності там, де ціна помилки вища.
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:manage` живе всередині адмін-моделі, а не роздається «кому завгодно».
.requestMatchers("/api/admin/users/**").hasAuthority("user:manage")
// Решта адмін-зони залишається за ширшим role-based правилом.
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Важливо: «широкі» правила (типу "/api/**") зазвичай ставлять нижче, щоб не перекрити це.
);
return http.build();
}
Це не означає, що роль ADMIN «погана». Просто на рівні зовнішнього рубежу вона відокремлює адмін-зону від решти світу, а authority на кшталт user:manage робить конкретну дію читабельною для аудиту, тестів і захисту на рівні сервісів.
Hardening-нюанс: порядок і «широкі» matcher’и
Адмін-зона часто страждає від «широких» правил вище по конфігурації. Наприклад, хтось поставив .requestMatchers("/api/**").authenticated() і нижче додав admin-правило — і потім дивується, чому адмінка стала доступною будь-якому залогіненому користувачу. У наступній лекції в нас буде troubleshooting, але вже зараз тримайте в голові hardening-ідею: «широкі правила мають бути нижче, вузькі — вище».
Нюанс: URL-маніпуляції і firewall
Ще один аспект «мінімальної поверхні» — коректні matcher’и і захист від дивних URL. Усередині FilterChainProxy Spring Security використовує HttpFirewall і за замовчуванням відхиляє ненормалізовані запити (на кшталт path-traversal послідовностей /../, дублікатів //, параметрів у шляху), щоб pattern-matching працював передбачувано.
Hardening тут простий: не починайте «послаблювати firewall», доки не розумієте, яку діру ви відкриваєте. Інакше ви легко перетворюєте «просто незручний кейс» на обхід правил.
4. File upload: ліміти, CSRF, зберігання
Файл — це не JSON. У JSON зазвичай є зрозумілий розмір, зрозуміла структура, валідатор і стандартні помилки. Файл же може бути величезним, дивним, бінарним, і ще він дуже любить, коли його зберігають «куди завгодно і як завгодно». Тому upload — класичний кандидат на hardening, навіть якщо доступ до кінцевої точки вже захищений.
У нашому проєкті upload — це POST /api/me/avatar. Він owner-based (лише «я сам»), і вже тому здається безпечним. Але hardening нагадує: безпека upload’у — це не лише «хто може викликати кінцеву точку», а й «що саме можна завантажити» та «що ми з цим робимо».
Обмеження розміру: multipart limits у Spring Boot
Якщо ви ніколи не ставили ліміти, то у вашого застосунку є прихована функція: «будь-хто може спробувати залити вам диск». І це точно не та «функція», якою варто пишатися на співбесіді. Хороша новина: Spring Boot уже за замовчуванням має обмеження на multipart upload і дозволяє їх зручно перевизначати.
За документацією Spring Boot для Spring MVC є дефолти: 1 MB на файл і 10 MB на всі файли в одному запиті, плюс набір властивостей для перевизначення та налаштування місця тимчасового зберігання. Безпечна позиція в hardening така: ліміт має бути явним і відповідати бізнес-кейсу. Для аватара 2 MB зазвичай більш ніж достатньо (якщо у вас аватар важить 40 MB — це вже не аватар, а заява про життєву позицію).
Мініконфіг:
spring:
servlet:
multipart:
# Максимальний розмір одного файла (аватар не має «пилососити» диск або пам’ять сервера).
max-file-size: 2MB
# Загальний розмір запиту (у нашому випадку він дорівнює розміру файла).
max-request-size: 2MB
Це налаштування не розв’яже всі проблеми upload’у, але воно закриває найпростіший клас ризиків: неконтрольований розмір.
Обмеження типу й імені: дозволений список > довіра до розширення
Майже кожен новачок колись наступає на граблі «перевірю розширення файла». Потім реальність каже: «перейменуй мені .exe на .png — і я подивлюся, що ти там перевіряєш». У hardening-мисленні розширення — це максимум підказка, але не критерій допуску.
Найпростіший і навчально зрозумілий підхід — дозволений список за Content-Type (а в реальному світі ще й перевірка сигнатури файла, але це вже за межами fundamentals).
import java.util.Set;
// Дозволяємо лише очікувані формати (allowlist), усе інше — одразу відхиляємо.
Set<String> allowed = Set.of("image/png", "image/jpeg");
// contentType беремо з multipart metadata (важливо пам’ятати: його можна підробити, але це добра базова фільтрація).
if (!allowed.contains(contentType)) {
// Повідомлення можна зробити більш «API-friendly», але сама ідея hardening — не приймати все підряд.
throw new IllegalArgumentException("Непідтримуваний тип аватара");
}
І так, це все ще не ідеальний захист (тому що Content-Type можна підробити), але як hardening-база для курсу — це чесний, зрозумілий крок: «ми не приймаємо все підряд».
CSRF + multipart: «курка й яйце» і чому header часто кращий
Далі починається те, що в документації Spring Security називають «проблемою курки й яйця». Щоб захиститися від 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), початкове ім’я зберігаємо лише як метадані, а шлях будуємо через 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-кінцевих точок, ви можете подарувати зовнішньому світу зайву інформацію про застосунок, оточення й конфігурацію. Тому hardening тут починається не з «як закрити», а з «що взагалі відкриваємо».
Spring Boot у документації прямо підкреслює, що кінцеві точки можуть містити чутливі дані, і їхню експозицію потрібно продумувати, а за замовчуванням через HTTP показується шлях на кшталт /actuator/{id}, і health відповідає /actuator/health.
Експозиція по мінімуму: що взагалі відкриваємо
Перший крок hardening — обмежити експозицію. У Boot 4 за замовчуванням через HTTP і JMX експонується лише health, а список експонованих кінцевих точок керується налаштуваннями management.endpoints.*.exposure.include/exclude.
Hardening-модель тут проста: якщо ви не впевнені, що кінцева точка безпечна для зовнішнього світу, вона не має бути експонованою.
Мінімальний і доволі типовий конфіг для навчального проєкту:
management:
endpoints:
web:
exposure:
# Явно перелічуємо, що віддаємо через HTTP (усе інше не експонується).
include: "health,info"
Це не означає, що info завжди можна відкривати публічно. Це означає, що ви обираєте маленький набір і далі вирішуєте доступ через правила безпеки.
Custom SecurityFilterChain і Actuator: чому Boot «відступає»
Дуже важливий нюанс, який часто ламає людям голову на реальних проєктах: якщо Spring Security є на classpath і немає вашого SecurityFilterChain, то Boot auto-configuration уміє «загалом правильно» прикрити actuator-кінцеві точки (зокрема, не залишати все відкритим). Але щойно ви визначаєте свій SecurityFilterChain, Boot auto-config back off, і правила доступу до actuator стають повністю вашою відповідальністю.
Це поворот у мисленні hardening: коли ви стаєте «дорослими» і пишете свій security chain, ви автоматично берете відповідальність і за операційні кінцеві точки. Це справедливо: якщо ви робите явну конфігурацію, отже ви хочете контролювати поведінку самі.
Окремий ланцюжок для 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-кінцевих точок (не до всього застосунку).
.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: переплутати «експозицію actuator-кінцевих точок» і «доступ до actuator-кінцевих точок».
Багато хто думає, що достатньо management.endpoints.web.exposure.include, і питання вирішене. Але це лише «які кінцеві точки існують через HTTP». Доступ до них за наявності кастомного SecurityFilterChain — ваша відповідальність. Spring Boot явно описує, що при кастомному ланцюжку auto-config відступає, і правила задаєте ви.
Помилка №3: зробити окремий SecurityFilterChain для actuator і забути про default chain.
Це особливо підступно, тому що в голові здається: «я ж усе покрив». Але якщо якийсь ланцюжок не матчиться — запит не захищений. Документація прямо каже: якщо жоден filter chain не підходить, запит не буде захищений.
Hardening тут — завжди тримати «головний» ланцюжок, який закриває решту запитів.
Помилка №4: вважати upload безпечним, бо він лише для власника.
Owner-only захищає від чужих користувачів, але не захищає від «поганих файлів». Величезний файл, непідтримуваний тип, дивне ім’я, небезпечний шлях зберігання — усе це лишається проблемою навіть для власника. Тому hardening upload включає ліміти й перевірки типу, а Spring Boot прямо описує наявність дефолтних лімітів і можливість їх перевизначати.
Помилка №5: ігнорувати нюанси CSRF multipart і виправляти все csrf().disable().
Multipart + CSRF — справді особливий випадок. Spring Security описує проблему «курки й яйця»: для CSRF потрібно прочитати тіло, але читання тіла означає завантаження файла, тому є компроміси, і для JS-клієнтів краще передавати токен у header.
Hardening — це зрозуміти компроміс і вибрати стратегію, а не вимикати механізм «бо він заважає завантажити аватар».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ