JavaRush /Курси /Spring Security /Hardening: документація, адмін-зона, завантаження, ops

Hardening: документація, адмін-зона, завантаження, ops

Spring Security
Рівень 28 , Лекція 2
Відкрита

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 — це зрозуміти компроміс і вибрати стратегію, а не вимикати механізм «бо він заважає завантажити аватар».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ