JavaRush /Курсы /Spring Security /CORS и preflight в Spring Security

CORS и preflight в Spring Security

Spring Security
12 уровень , 1 лекция
Открыта

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

Если вы когда-нибудь видели в консоли браузера что-то вроде «Blocked by CORS policy», то вы уже сталкивались с ситуацией, где ваш backend, возможно, даже честно вернул ответ — но браузер сказал: «Пользователь, извини, я это твоей странице не покажу». Самый частый триггер этой истории — preflight-запрос, тот самый OPTIONS, который прилетает раньше “настоящего” PATCH/POST/DELETE.

Origin-картину мы уже зафиксировали: в нашем local baseline страница живёт на 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/редиректом на login page. Браузеру от этого не легче: 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 Browser
    participant S as "Spring Security (filter chain)"

    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-сервер фронтенда). Мы будем стремиться к двум вещам одновременно: чтобы работало без шаманства и чтобы не выглядело как «разрешить всё всем навсегда» (потому что это обычно и есть причина, почему потом становится больно).

Возьмём тот же local сценарий http://localhost:5173 -> http://localhost:8080 и соберём CORS-политику так, чтобы потом к ней осталось лишь добавить cookie и /csrf, а не переписывать всё заново.

Настройки в application.yml

Начнём с того, что origin’ы — это конфигурация окружения. Сегодня вы тестируете с http://localhost:5173, завтра это будет другой browser-origin. Поэтому вынесем их в application.yml:

app:
  security:
    cors:
      allowed-origins:
        # На текущем local baseline нам достаточно одного 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.

1
Задача
Spring Security, 12 уровень, 1 лекция
Недоступна
Явная CORS-конфигурация для preflight
Явная CORS-конфигурация для preflight
1
Задача
Spring Security, 12 уровень, 1 лекция
Недоступна
Preflight проходит, а защищённый запрос остаётся защищённым
Preflight проходит, а защищённый запрос остаётся защищённым
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ