JavaRush /Курси /Spring Security /CORS і preflight у Spring Security

CORS і preflight у Spring Security

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

1. Проблема: origin, preflight і OPTIONS

Якщо ви хоч раз бачили в консолі браузера щось на кшталт «Заблоковано політикою CORS», то вже стикалися із ситуацією, коли ваш backend, можливо, навіть чесно повернув відповідь, але браузер вирішив: «Користувачу, вибачте, я це вашій сторінці не покажу». Найчастіше таке стається через preflight-запит — той самий OPTIONS, який прилітає раніше за «справжній» PATCH/POST/DELETE.

Origin-картину ми вже зафіксували: у нашому локальному базовому варіанті сторінка працює на http://localhost:5173, а API — на http://localhost:8080, тому браузер вмикає правила cross-origin. Тепер важливе інше питання: чому до контролера іноді навіть не доходить основний PATCH або DELETE — і чому браузер узагалі починає розмову із сервером з окремого OPTIONS.

Уявімо найживіший сценарій із нашої реальності: фронтенд запущено на http://localhost:5173, а наш Secure Content Platform API — на http://localhost:8080. Хост той самий (localhost), але порт інший, отже для браузера це cross-origin. І ось сторінка намагається оновити профіль:

endpoint: PATCH /api/me/profile
тіло: JSON
заголовок: Content-Type: application/json
плюс X-CSRF-TOKEN: ... (бо в нас stateful + CSRF)

Що робить браузер? Часто спершу він надсилає «розвідувальний запит» (preflight), приблизно так:

# Preflight-запит, який браузер надсилає перед «справжнім» PATCH
OPTIONS /api/me/profile HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, x-csrf-token

І тепер важлива деталь: якщо сервер на цей OPTIONS відповів не так, як очікує модель CORS, браузер не надішле основний PATCH. У DevTools ви бачитимете, що «запит не проходить», але насправді його навіть не було надіслано в бойовій формі — його зупинили на етапі «чи можна так робити?».

Типовий збій виглядає так: Spring Security за замовчуванням може вирішити, що OPTIONS /api/me/profile — це «якийсь підозрілий запит без автентифікації», і відповісти 401/403 або редиректом на сторінку входу. Браузеру від цього не легше: preflight не пройшов, отже JavaScript не отримає доступу до відповіді, і ви побачите CORS-помилку, навіть якщо сервер загалом працює.

Щоб preflight пройшов, сервер має повернути зрозумілу CORS-відповідь, де явно сказано: «так, для цього origin можна, такими методами можна, такі заголовки можна, і так — cookies теж можна, якщо ви їх використовуєте». Спростімо це так:

# Відповідь на preflight: саме ці заголовки браузер перевіряє перед тим, як надсилати основний запит
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PATCH,DELETE
Access-Control-Allow-Headers: content-type,x-csrf-token
Access-Control-Allow-Credentials: true

І лише після цього браузер уже робить справжній запит.

2. Ментальна модель CORS: origins, methods, headers

Коли ви вперше починаєте налаштовувати CORS, легко сприйняти його як «таблицю дозволів на сервері». Але правильніше думати про CORS як про перемовини між браузером і сервером. Браузер ставить запитання («чи можна ось так?»), сервер відповідає («можна» або «не можна»), а браузер уже вирішує, дати JavaScript-коду доступ до відповіді чи ні. Сервер при цьому не «впускає» запит у систему — він лише повідомляє правила, а enforcement виконує браузер.

Найзручніше не плутатися, якщо тримати в голові дві групи заголовків.

Перша група — те, що браузер кладе в запит:

Origin — хто ініціатор, тобто звідки сторінка
Access-Control-Request-Method — який метод хоче виконати сторінка
Access-Control-Request-Headers — які заголовки хоче надіслати сторінка

Друга група — те, що сервер повертає у відповіді:

Access-Control-Allow-Origin — для якого origin дозволяємо доступ до відповіді
Access-Control-Allow-Methods — якими методами дозволяємо користуватися
Access-Control-Allow-Headers — які request headers дозволяємо надсилати
Access-Control-Allow-Credentials — чи можна надсилати cookies/credentials
Access-Control-Max-Age — як довго браузер може кешувати результат preflight

Дуже корисно бачити відповідність «налаштування у Spring» ↔ «заголовок у HTTP». Зведімо це в маленьку таблицю — це одна з тих рідкісних таблиць, які справді економлять нерви:

Що налаштовуємо у Spring (CorsConfiguration) Що отримає браузер у відповіді Навіщо це потрібно
allowedOrigins Access-Control-Allow-Origin Дозволити конкретні origin-и
allowedMethods Access-Control-Allow-Methods Дозволити методи (PATCH, DELETE тощо)
allowedHeaders Access-Control-Allow-Headers Дозволити нестандартні або потрібні заголовки, наприклад X-CSRF-TOKEN
allowCredentials Access-Control-Allow-Credentials Дозволити надсилати cookies/credentials у cross-origin
maxAge Access-Control-Max-Age Кешувати preflight, щоб не стріляти OPTIONS кожні 5 секунд
exposedHeaders Access-Control-Expose-Headers Дозволити JS читати деякі response headers

Тепер два важливі практичні моменти, які новачку зазвичай не пояснюють, а він потім знаходить їх на власному досвіді.

Перший момент: JSON майже завжди приводить до preflight. Навіть якщо ви не надсилаєте жодних «кастомних» заголовків, Content-Type: application/json не вважається «простим» content-type у моделі CORS. Тобто звичайний REST-запит із JSON-тілом у браузері — це майже гарантовано OPTIONS перед ним. А якщо ви додали X-CSRF-TOKEN, то лише посилили цю гарантію. Тож preflight — не рідкісний edge case, а ваша щоденна реальність.

Другий момент: credentials у CORS — це не «якісь логіни», а дуже конкретно про те, що браузер може прикладати cookies та інші credentials до cross-origin-запиту. Для нашої stateful-моделі це критично, бо JSESSIONID живе в cookie. Якщо credentials заборонені, ви хоч тисячу разів будете логінитися, але браузер не надішле session cookie в cross-origin-запит — і backend бачитиме вас як anonymous.

І тут є важливе правило безпеки: якщо ви ввімкнули allowCredentials(true), то не можете відповісти Access-Control-Allow-Origin: *. Браузер таке не прийме. Та й за змістом це небезпечно: «дозволяю будь-якому origin надсилати мої cookies» — звучить як анекдот, який погано закінчується. Тому для session-based CORS майже завжди потрібні явні origin-и.

3. CORS у Spring Security: фільтри і порядок

Коли ми говоримо «налаштувати CORS у Spring Security», це звучить так, ніби ми додаємо ще одне правило «перед контролером». Але на практиці CORS — це окремий шматок інфраструктури, який має відпрацювати на самому початку filter chain, інакше preflight почне впиратися в автентифікацію або авторизацію, і браузер навіть не дійде до «справжнього» запиту. Простіше кажучи: спочатку домовимося з браузером, чи можна, а вже потім розбиратимемося, хто ви і що вам можна.

У servlet-стеку Spring CORS зазвичай реалізується через CorsFilter. Його ідея проста: на вході запиту він дивиться на Origin і, якщо потрібно, на preflight-заголовки, шукає відповідну конфігурацію та додає у відповідь потрібні Access-Control-* заголовки. А якщо це preflight (OPTIONS із потрібними заголовками), він може завершити запит раніше, навіть не доходячи до DispatcherServlet.

У Spring Security ви вмикаєте CORS-підтримку в SecurityFilterChain через http.cors(...). У цей момент Spring Security вбудовує CORS-обробку в ланцюг фільтрів, щоб вона відпрацювала в правильному місці й не конфліктувала з рештою security-механіки.

Схематично хороша картина виглядає так:

sequenceDiagram
    participant B as "Браузер"
    participant S as "Spring Security (ланцюг фільтрів)"

    B->>S: "OPTIONS /api/me/profile (preflight)"
    S-->>B: 200 + Access-Control-*

    B->>S: "PATCH /api/me/profile (cookie + CSRF)"
    S-->>B: 200 (звичайна відповідь контролера)

Із цієї схеми випливає практичне правило: якщо у вас є CorsConfigurationSource, але ви не ввімкнули .cors(...) у SecurityFilterChain, то ваш OPTIONS із високою ймовірністю піде далі по security-фільтрах і там або отримає 401/403, або повернеться без потрібних CORS-заголовків. А браузер на це скаже: «Дякую, але ні».

Ще один момент, який корисно тримати в голові: CORS у Spring можна налаштовувати в кількох місцях. Можна розкидати @CrossOrigin по контролерах, можна додати глобальне налаштування через Spring MVC, можна зробити єдиний CorsConfigurationSource і підключити його до Security. Для навчального проєкту та для безпеки мислення корисніша остання стратегія: одна точка правди, читабельна конфігурація і мінімум сюрпризів.

4. Реалізація: CORS-конфігурація в API

Тепер зробімо те, заради чого все й затівалося: додамо в наш проєкт зрозумілу CORS-конфігурацію, розраховану на окремий browser-origin, наприклад dev-сервер фронтенду. Ми прагнутимемо до двох речей одночасно: щоб усе працювало без шаманства і щоб це не виглядало як «дозволити все всім назавжди» — бо саме так зазвичай і починаються проблеми.

Візьмімо той самий локальний сценарій http://localhost:5173 -> http://localhost:8080 і зберемо CORS-політику так, щоб потім до неї залишилося лише додати cookie і /csrf, а не переписувати все заново.

Налаштування в application.yml

Почнімо з того, що origin-и — це конфігурація середовища. Сьогодні ви тестуєте з http://localhost:5173, завтра це буде інший browser-origin. Тому винесімо їх у application.yml:

app:
  security:
    cors:
      allowed-origins:
        # На поточному локальному базовому варіанті нам достатньо одного browser-origin'у
        - "http://localhost:5173"

Зверніть увагу на дві дрібниці: тут немає шляхів (/api), немає кінцевих слешів, і порт указано явно, якщо він не стандартний. Це саме origin, як ми й домовлялися на минулій лекції.

Properties-обʼєкт (коротко і по суті)

Зробімо маленький record для properties, щоб не тягнути @Value по всьому проєкту:

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.List;

@ConfigurationProperties(prefix = "app.security.cors")
// Список origin-ів, яким можна читати відповіді API з браузера
public record CorsProps(List<String> allowedOrigins) { }

І увімкнімо його в контексті в окремому конфігураційному класі, щоб не перетворювати SecurityConfig на величезний комбайн:

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(CorsProps.class) // Підхоплюємо налаштування з application.yml
class SecurityPropsConfig { }

Зберімо CorsConfigurationSource

Тепер зробімо bean CorsConfigurationSource, який відповідатиме на головне питання: «які CORS-правила застосовуються до яких шляхів?». Для навчального проєкту логічно повісити CORS на /api/** — саме там у нас живе API.

import org.springframework.context.annotation.Bean;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource(CorsProps props) {
    CorsConfiguration cfg = new CorsConfiguration();

    // Хто може звертатися до API з браузера (важливо: це саме origin, без path)
    cfg.setAllowedOrigins(props.allowedOrigins());

    // Які методи ми дозволяємо для cross-origin-запитів
    cfg.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));

    // Які заголовки клієнт має право надсилати (інакше preflight буде відхилено браузером)
    cfg.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));

    // Дозволяємо cookies (наприклад, JSESSIONID) у cross-origin-запитах
    cfg.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    // Застосовуємо ці CORS-правила лише до API-роутів
    source.registerCorsConfiguration("/api/**", cfg);

    return source;
}

Тут усе спеціально «вузько»: конкретні origin-и, конкретні методи, конкретні заголовки. Так, можна зробити *, але це як «полагодити проводку, прибравши запобіжник» — лампочка загориться, але жити ви будете тривожно.

Окремо про allowCredentials(true): у stateful-моделі це майже обовʼязково, інакше session cookie просто не братиме участі в cross-origin-запитах.

Якщо хочете трохи зменшити «шум» від постійних preflight-запитів, можна додати кешування preflight без фанатизму:

cfg.setMaxAge(1800L); // 30 хвилин, значення в секундах

Підключимо CORS до SecurityFilterChain і дозволимо OPTIONS

Тепер найважливіше: вмикаємо .cors(...) у ланцюг і явно пропускаємо preflight, щоб він не впирався в «спочатку автентифікуйся».

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.CorsUtils;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
                                        CorsConfigurationSource corsSource) throws Exception {
    // Важливо: CORS має бути ввімкнено в SecurityFilterChain, інакше preflight упирається в security-фільтри
    http.cors(cors -> cors.configurationSource(corsSource));

    http.authorizeHttpRequests(auth -> auth
            // Preflight не є «бізнес-запитом», його зазвичай безпечно пропускати
            .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated()
    );

    return http.build();
}

Дозвіл preflight часто викликає внутрішній протест: «як це — відкрити доступ?». Але preflight сам по собі не дає доступу до ваших даних: він лише дозволяє браузеру вирішити, чи може сторінка зробити справжній запит. Справжній PATCH /api/me/profile усе одно залишиться під authenticated() і під CSRF.

Швидка перевірка через curl без фронтенду

Навіть без написання фронтенду можна перевірити preflight вручну, щоб перестати «гадати по консолі браузера».

curl -i -X OPTIONS "http://localhost:8080/api/me/profile" \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: PATCH" \
  -H "Access-Control-Request-Headers: content-type,x-csrf-token"

У відповіді ви хочете побачити хоча б Access-Control-Allow-Origin, Access-Control-Allow-Methods і Access-Control-Allow-Headers. Якщо їх немає — браузер не буде щасливим, навіть якщо ваш контролер ідеальний і усміхається вам із IDE.

5. Anti-patterns у CORS

Коли CORS нарешті починає працювати, зʼявляється спокуса «закріпити успіх» і відкрити все взагалі. Це людськи зрозуміло: хочеться, щоб вкладка Network була зеленою, а не щоб ви читали простирадло заголовків. Але з інженерного погляду CORS — це місце, де краще бути нудним і обережним. Нудна конфігурація — це комплімент вашому майбутньому.

Найтиповіший небезпечний прийом — поставити allowedOrigins(*), allowedMethods(*), allowedHeaders(*) і заспокоїтися. Це не «полагодив», це «перестав вимірювати температуру, щоб не бачити хворобу». У session-based-моделі ви ще й зіткнетеся з тим, що allowCredentials(true) конфліктує з *, тож «універсальна зірочка» часто не працює навіть технічно.

Ще один анти-патерн — розкидати @CrossOrigin по контролерах. У якийсь момент ви забудете, що в одному контролері дозволено один origin, а в іншому — інший, і почнете ловити «воно працює, але іноді» — найкоштовніший вид бага. Для навчального проєкту краще одна точка правди: CorsConfigurationSource, зареєстрований на потрібні шляхи.

Окремо важливо не плутати CORS із реальним захистом API. CORS не забороняє зловмиснику або просто будь-якому скрипту на сервері викликати ваш endpoint через curl/requests/HttpClient. CORS лише обмежує, який JavaScript-код у браузері може прочитати відповідь. Тому не можна проєктувати безпеку як «у мене CORS налаштовано, отже чужі не дістануть дані». Дістануть. Просто не з браузера, а із сервера. Справжній захист залишається в SecurityFilterChain, ролях і правилах доступу.

І нарешті, погана звичка, яку хочеться прибити мʼяко, але впевнено: «не працює PATCH — вимкну CSRF». Дуже часто PATCH не працює тому, що preflight не пройшов через CORS-налаштування або не дозволено потрібний заголовок, а CSRF тут узагалі ні до чого. Вимкнувши CSRF, ви іноді «випадково» зробите запит простим або зміните поведінку клієнта, але не вилікуєте причину. Ви просто зміните симптоми — як людина, яка заклеїла лампочку «Check Engine» ізолентою.

6. Типові помилки під час роботи з CORS

CORS-помилки підступні тим, що часто виглядають однаково: «CORS policy blocked…». Але причини там можуть бути зовсім різні, і правильна звичка — одразу перевіряти, на якому саме етапі все ламається: preflight не пройшов, заголовок не дозволено, credentials не ввімкнено або ви взагалі забули підключити CORS до filter chain.

Помилка №1: є CorsConfigurationSource, але .cors(...) у SecurityFilterChain не ввімкнено.
Таке часто трапляється, коли конфігурацію написали, але забули підключити. У результаті ви очікуєте, що Spring віддаватиме Access-Control-Allow-*, а preflight іде далі по security-фільтрах і впирається в authenticated() або в редирект. Лікується просто: CORS — частина ланцюга, отже її потрібно явно ввімкнути в chain.

Помилка №2: дозволили allowedOrigins, але вказали не origin, а URL зі шляхом.
http://localhost:3000/api — це не origin, і браузер не буде порівнювати це так, як ви задумали. Він порівнює лише схему, хост і порт. Якщо ви додали шлях, ви просто записали рядок, який ніколи не збіжиться. Те саме стосується кінцевих слешів і «майже однакових» портів.

Помилка №3: ввімкнули allowCredentials(true) і водночас дозволили origin *.
Це класика. У найкращому випадку ви отримаєте некоректні CORS-заголовки, які браузер відхилить. У більш суворих конфігураціях Spring може навіть відмовити вам у такому налаштуванні, бо воно суперечить ідеї credentials. Якщо ви використовуєте cookies, а ми саме так і робимо в session-based-моделі, указуйте origin-и явно.

Помилка №4: забули додати потрібні request headers у allowedHeaders.
Якщо ваш клієнт надсилає Content-Type: application/json, X-CSRF-TOKEN або будь-який інший не «простий» заголовок, його треба дозволити. Інакше preflight запитає: «чи можна надіслати x-csrf-token?», а сервер промовчить або скаже «ні», і браузер не піде далі. Це часто виглядає як «у мене не працює PATCH», хоча PATCH просто не відбувся.

Помилка №5: блокувати OPTIONS як «підозрілий метод».
Інтуїтивно хочеться закрити все, що не GET/POST. Але OPTIONS у браузерному світі — це не «бізнес-операція», а частина механізму CORS. Якщо ви не дозволите preflight проходити, ви зламаєте cross-origin-виклики навіть для публічних ресурсів. Дозволяти OPTIONS глобально зазвичай безпечніше, ніж потім намагатися точково вгадувати, де браузер робитиме preflight.

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