Всем привет! Что-то давненько статей не было, так что надо бы это исправить.

В этой статье я расскажу что такое CSRF и CORS-атаки, и как Spring Security обеспечивает защиту от этих атак. Для начала разберём в теории, что это за атаки.

CSRF

CSRF (Cross-Site Request Forgery) - это атака, которая заставляет пользователя сделать нежелательные действия на сайте, где он уже авторизован. Приведу пример для лучшего понимания: для начала вы проходите процесс авторизации на каком-либо важном сайте, к примеру на сайте банка, затем по случайности или из интереса переходите на другой сайт, а в это время на сайте крутится скрытый вредоносный скрипт или загружается невидимая форма, и этот скрипт отправляет запрос по типу: сними деньги со счёта в банке. В этот момент браузер видит, что запрос идёт на сайт банка, в котором вы уже авторизованы, и он автоматически подставляет авторизационные cookie только потому, что они уже были выданы банком после вашей авторизации. Именно поэтому сервер считает, что запрос был отправлен вами, если не использовать дополнительные механизмы защиты, такие как CSRF-токены.

CORS

Тут главное не запутаться. CORS - это технология защиты браузера от чтения ответов посторонними сайтами, то есть это инструкция для браузера, которая указывает, каким чужим сайтам можно читать данные с нашего сервера. Но при этом CORS это ещё и сокращение от Cross-Origin Resource Sharing, тут правда главное не запутаться. Получается довольно забавно, что CORS-атака использует криво настроенный CORS. Теперь на примере с тем же банком: в базе данных лежит какая-нибудь секретная информация пользователя (баланс, история переводов и тп), и разработчик криво настроил CORS, и разрешил любому сайту читать ответы сервера. Так что при любом запросе к банку, он будет возвращать заголовки Access-Control-Allow-Origin с тем Origin, который пришёл в запросе и Access-Control-Allow-Credentials: true (разрешает браузеру предоставлять доступ к ответу, если запрос отправлен с cookie)

Теперь когда мы поняли, что это такое, можно узнать как Spring Security обеспечивает защиту от этих атак.

Защита от CSRF-атаки

Если мы используем JWT, и не кладём его в cookie, то переживать нам не нужно и можем в SecurityConfig оставить

http.csrf(csrf -> csrf.disable())

потому, что Bearer-токен браузер не добавляет к запросам автоматически (в отличие от cookie), поэтому чужой сайт физически не сможет подставить его в свой запрос.

Но если мы будем использовать sessison + cookie, то CSRF будет сильной угрозой, но в нашем случае т.к. мы используем REST взаимодействие, а не HTML-формы, токен передаётся в заголовке, а не в скрытом поле формы, и мы можем написать в SecurityConfig следующим образом:

http.csrf(csrf ->
    csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))

Что тут происходит? Если пользователь впервые обратился к нашему сервису, то Spring Security проверит, есть ли у него CSRF-токен, если его нет, то CookieCsrfTokenRepository создаст новый случайный токен, который выглядит примерно так: 7f3a8c9d-0e1b-4a7b-bc1d, далее этот же CookieCsrfTokenRepository сохранит его в cookie: XSRF-TOKEN (одно и то же что CSRF, только используют X вместо C, дабы избежать путаницы), а дальше Spring сравнивает токен из cookie с токеном из заголовка X-XSRF-TOKEN (который фронтенд добавляет самостоятельно). Теперь почему мы используем withHttpOnlyFalse()? Вообще по умолчанию cookie можно сделать HttpOnly, тогда JS не сможет её прочитать, но в нашем случае этого делать не нужно, так как нам нужно, чтобы фронтенд мог прочитать cookie, по этому и withHttpOnlyFalse().

Можем теперь разобрать это на примере:

  1. Браузер получил ответ от сервера в котором были установлены 2 cookie: JSESSIONID=1 и XSRF-TOKEN= 7f3a8c9d-0e1b-4a7b-bc1d

  2. Когда фронтенд делает POST-запрос (или же PUT/DELETE), он в headers добавляет поле "X-XSRF-TOKEN" : " 7f3a8c9d-0e1b-4a7b-bc1d"

  3. Во время обращения к серверу, сам браузер отправляет XSRF-TOKEN и фронтенд добавляет X-XSRF-TOKEN в header'е запроса

  4. Spring Security сравнивает пришедшие cookie и header запроса, и если значения совпадают, то запрос разрешается, а если нет заголовка или header'a, то возвращается 403 Forbidden.

Но теперь возникает вопрос, почему злоумышленник не может подделать заголовок? Всё достаточно просто, когда мы переходим на сторонний сайт с вредоносным скриптом, он не может прочитать XSRF-TOKEN из cookie, потому что действует политика Same Origin Policy, которая запрещает JS одного сайта получать доступ к данным другого сайта.

Если кратко резюмировать, то SOP запрещает доступ к данным другого сайта, по этому злоумышленник не может узнать значение CSRF-токена и подделать заголовок X-XSRF-TOKEN.

Защита от CORS-атаки

Как я писал выше, по сути CORS - это инструкция. Представим, что у нас есть какой-нибудь фронтенд на localhost:3030 и бэкенд на localhost:8080

В SecurityConfig нашего бэкенда нужно написать специальный бин, и затем написать настройку в цепочке фильтров

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        // Создаём конфиг
        CorsConfiguration config = new CorsConfiguration();
        //Внедряем все доверенные источники из списка
        config.setAllowedOrigins(List.of("http://localhost:3030", "https://myapp.com"));
        //Говорим какие HTTP-методы можно использовать
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        //Заголовки, которые клиент может отправлять в запросе
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        //Разрешаем браузеру отправлять cookie и другие учётные данные
        config.setAllowCredentials(true);
        //На сколько секунд браузер может закэшировать результат preflight-запроса
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        //Применяем CORS-конфигурацию ко всем URL приложения  
        source.registerCorsConfiguration("/**", config);
        return source;
    }

и в цепочке фильтров нужно добавить:

.cors(cors ->
      cors.configurationSource(corsConfigurationSource()))

тут уже идёт внедрение нашего конфига в цепочку фильтров.

Если мы настроим конфиг не правильно, или просто забьём на него, то это будет чревато кражей данных, так что про него забывать нельзя, если мы хотим сделать приложение безопасным.

Есть важный нюанс, что настройке CORS метод setAllowedOrigins и setAllowCredentials(true) работают вместе только если указаны явно, а не через wildcard "*". Потому что если бы был "*", то браузер бы блокировал запрос пот allowCredentials(true). Но тут мы прописали сами конкретные домены, так что всё хорошо.

И ещё небольшое уточнение по стилю. Когда мы вызываем corsConfigurationSource() внутри cors.configurationSource(), всё работает корректно, потому что @Configuration классы по умолчанию проксируются с помощью CGLIB и повторный вызов метода вернёт тот же бин из контекста, а не создаст новый, также и с authenticationProvider().

В целом на этом можно завершить данную статью. Весь код будет уже лежит в моём гитхабе: https://github.com/PavelKhalov/securityCourse
Спасибо всем кто дочитал, надеюсь она была для вас полезной. Аргументированная критика как обычно приветствуется)