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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ