1. Обзор
В этом руководстве мы обсудим атаки подделки межсайтовых запросов (CSRF) и способы их предотвращения с помощью Spring Security.
2. Две простые CSRF-атаки
Существует несколько форм CSRF-атак. Давайте обсудим некоторые из наиболее распространенных.
2.1. ПОЛУЧИТЬ примеры
Давайте рассмотрим следующий запрос GET
, используемый вошедшим в систему пользователем для перевода денег на определенный банковский счет 1234
:
GET http://bank.com/transfer?accountNo=1234&amount=100
Если злоумышленник вместо этого хочет перевести деньги со счета жертвы на свой собственный счет — 5678
— ему нужно заставить жертву инициировать запрос:
GET http://bank.com/transfer?accountNo=5678&amount=1000
Есть несколько способов сделать это:
- Ссылка — Злоумышленник может убедить жертву перейти по этой ссылке, например, для выполнения перевода:
<a href="http://bank.com/transfer?accountNo=5678&amount=1000">
Show Kittens Pictures
</a>
- Изображение — злоумышленник может использовать
тег <img/>
с целевым URL-адресом в качестве источника изображения. Другими словами, щелчок даже не нужен. Запрос будет автоматически выполнен при загрузке страницы:
<img src="http://bank.com/transfer?accountNo=5678&amount=1000"/>
2.2. Пример POST
Предположим, что основной запрос должен быть POST-запросом:
POST http://bank.com/transfer
accountNo=1234&amount=100
В этом случае злоумышленнику необходимо, чтобы жертва выполнила аналогичный запрос:
POST http://bank.com/transfer
accountNo=5678&amount=1000
В этом случае не будут работать ни теги <a> ,
ни <img/> .
Злоумышленнику понадобится <form>
:
<form action="http://bank.com/transfer" method="POST">
<input type="hidden" name="accountNo" value="5678"/>
<input type="hidden" name="amount" value="1000"/>
<input type="submit" value="Show Kittens Pictures"/>
</form>
Однако форма может быть отправлена автоматически с помощью JavaScript:
<body onload="document.forms[0].submit()">
<form>
...
2.3. Практическое моделирование
Теперь, когда мы понимаем, как выглядит атака CSRF, давайте смоделируем эти примеры в приложении Spring.
Мы собираемся начать с реализации простого контроллера — BankController
:
@Controller
public class BankController {
private Logger logger = LoggerFactory.getLogger(getClass());
@RequestMapping(value = "/transfer", method = RequestMethod.GET)
@ResponseBody
public String transfer(@RequestParam("accountNo") int accountNo,
@RequestParam("amount") final int amount) {
logger.info("Transfer to {}", accountNo);
...
}
@RequestMapping(value = "/transfer", method = RequestMethod.POST)
@ResponseStatus(HttpStatus.OK)
public void transfer2(@RequestParam("accountNo") int accountNo,
@RequestParam("amount") final int amount) {
logger.info("Transfer to {}", accountNo);
...
}
}
И давайте также иметь базовую HTML-страницу, которая запускает операцию банковского перевода:
<html>
<body>
<h1>CSRF test on Origin</h1>
<a href="transfer?accountNo=1234&amount=100">Transfer Money to John</a>
<form action="transfer" method="POST">
<label>Account Number</label>
<input name="accountNo" type="number"/>
<label>Amount</label>
<input name="amount" type="number"/>
<input type="submit">
</form>
</body>
</html>
Это страница основного приложения, работающего на исходном домене.
Следует отметить, что мы реализовали GET
через простую ссылку и POST
через простую <form>
.
Теперь давайте посмотрим, как будет выглядеть страница злоумышленника:
<html>
<body>
<a href="http://localhost:8080/transfer?accountNo=5678&amount=1000">Show Kittens Pictures</a>
<img src="http://localhost:8080/transfer?accountNo=5678&amount=1000"/>
<form action="http://localhost:8080/transfer" method="POST">
<input name="accountNo" type="hidden" value="5678"/>
<input name="amount" type="hidden" value="1000"/>
<input type="submit" value="Show Kittens Picture">
</form>
</body>
</html>
Эта страница будет работать на другом домене — домене злоумышленника.
Наконец, давайте запустим исходное приложение и приложение злоумышленника локально.
Чтобы атака сработала, пользователь должен пройти аутентификацию в исходном приложении с помощью файла cookie сеанса.
Давайте сначала получим доступ к исходной странице приложения:
http://localhost:8081/spring-rest-full/csrfHome.html
Он установит файл cookie JSESSIONID
в нашем браузере.
Затем давайте получим доступ к странице злоумышленника:
http://localhost:8081/spring-security-rest/api/csrfAttacker.html
Если мы отследим запросы, исходящие с этой страницы злоумышленника, мы сможем определить те, которые попали в исходное приложение. Поскольку файл cookie JSESSIONID
автоматически отправляется с этими запросами, Spring аутентифицирует их, как если бы они исходили из исходного домена.
3. Приложение Spring MVC
Для защиты приложений MVC Spring добавляет токен CSRF к каждому сгенерированному представлению. Этот токен должен отправляться на сервер при каждом HTTP-запросе, который изменяет состояние (PATCH, POST, PUT и DELETE, а `` не GET). Это защищает наше приложение от CSRF-атак, поскольку злоумышленник не может получить этот токен со своей собственной страницы.
Далее мы увидим, как настроить безопасность нашего приложения и как сделать наш клиент совместимым с ней.
3.1. Конфигурация безопасности Spring
В более старой конфигурации XML (до Spring Security 4) защита CSRF была отключена по умолчанию, и мы могли включить ее по мере необходимости:
<http>
...
<csrf />
</http>
Начиная с Spring Security 4.x защита CSRF включена по умолчанию.
Эта конфигурация по умолчанию добавляет токен CSRF к атрибуту HttpServletRequest
с именем _csrf
.
Если нам нужно, мы можем отключить эту конфигурацию:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable();
}
3.2. Конфигурация клиента
Теперь нам нужно включить токен CSRF в наши запросы.
Атрибут _csrf
содержит следующую информацию:
token
— значение токена CSRFимя_параметра
— имя параметра HTML-формы, которое должно включать значение токена.headerName
— имя HTTP-заголовка, который должен включать значение токена
Если наши представления используют HTML-формы, мы будем использовать значения параметраName
и токена
для добавления скрытого ввода:
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
Если наши представления используют JSON, нам нужно использовать значения headerName
и token
для добавления HTTP-заголовка.
Сначала нам нужно включить значение токена и имя заголовка в метатеги:
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
Затем давайте получим значения метатегов с помощью JQuery:
var token = $("meta[name='_csrf']").attr("content");
var header = $("meta[name='_csrf_header']").attr("content");
Наконец, давайте используем эти значения для установки нашего заголовка XHR:
$(document).ajaxSend(function(e, xhr, options) {
xhr.setRequestHeader(header, token);
});
4. Spring API без сохранения состояния
Давайте рассмотрим случай, когда Spring API без сохранения состояния используется внешним интерфейсом.
Как объясняется в нашей специальной статье , нам нужно понять, требуется ли защита CSRF для нашего API без сохранения состояния.
Если наш API без сохранения состояния использует аутентификацию на основе токенов, такую как JWT, нам не нужна защита CSRF, и мы должны отключить ее, как мы видели ранее.
Однако, если наш API без сохранения состояния использует аутентификацию cookie сеанса, нам необходимо включить защиту CSRF **, как мы увидим далее.
**
4.1. Внутренняя конфигурация
Наш API без сохранения состояния не может добавить токен CSRF, как наша конфигурация MVC, потому что он не генерирует никакого HTML-представления.
В этом случае мы можем отправить токен CSRF в файле cookie, используя CookieCsrfTokenRepository
:
@Configuration
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws {
http
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
}
Эта конфигурация установит файл cookie XSRF-TOKEN
для внешнего интерфейса. Поскольку для флага HTTP-only мы установили значение
false
, внешний интерфейс сможет получить этот файл cookie с помощью JavaScript.
4.2. Интерфейсная конфигурация
С помощью JavaScript нам нужно найти значение файла cookie XSRF-TOKEN
в списке document.cookie .
Поскольку этот список хранится в виде строки, мы можем получить его с помощью этого регулярного выражения:
const csrfToken = document.cookie.replace(/(?:(?:^|.*;\s*)XSRF-TOKEN\s*\=\s*([^;]*).*$)|^.*$/, '$1');
Затем мы должны отправлять токен на каждый запрос REST, который изменяет состояние API: POST, PUT, DELETE и PATCH.
Spring ожидает получить его в заголовке X-XSRF-TOKEN
.
Мы можем просто установить его с помощью JavaScript Fetch
API:
fetch(url, {
method: 'POST',
body: /* data to send */,
headers: { 'X-XSRF-TOKEN': csrfToken },
})
5. Тест с отключенным CSRF
Со всем этим на месте, давайте проведем небольшое тестирование.
Давайте сначала попробуем отправить простой запрос POST, когда CSRF отключен:
@ContextConfiguration(classes = { SecurityWithoutCsrfConfig.class, ...})
public class CsrfDisabledIntegrationTest extends CsrfAbstractIntegrationTest {
@Test
public void givenNotAuth_whenAddFoo_thenUnauthorized() throws Exception {
mvc.perform(
post("/foos").contentType(MediaType.APPLICATION_JSON)
.content(createFoo())
).andExpect(status().isUnauthorized());
}
@Test
public void givenAuth_whenAddFoo_thenCreated() throws Exception {
mvc.perform(
post("/foos").contentType(MediaType.APPLICATION_JSON)
.content(createFoo())
.with(testUser())
).andExpect(status().isCreated());
}
}
Здесь мы используем базовый класс для хранения общей вспомогательной логики тестирования — CsrfAbstractIntegrationTest
:
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
public class CsrfAbstractIntegrationTest {
@Autowired
private WebApplicationContext context;
@Autowired
private Filter springSecurityFilterChain;
protected MockMvc mvc;
@Before
public void setup() {
mvc = MockMvcBuilders.webAppContextSetup(context)
.addFilters(springSecurityFilterChain)
.build();
}
protected RequestPostProcessor testUser() {
return user("user").password("userPass").roles("USER");
}
protected String createFoo() throws JsonProcessingException {
return new ObjectMapper().writeValueAsString(new Foo(randomAlphabetic(6)));
}
}
Следует отметить, что запрос был успешно выполнен, когда у пользователя были правильные учетные данные безопасности — никакой дополнительной информации не требовалось.
Это означает, что злоумышленник может просто использовать любой из рассмотренных ранее векторов атаки для компрометации системы.
6. Тест с поддержкой CSRF
Теперь давайте включим защиту CSRF и увидим разницу:
@ContextConfiguration(classes = { SecurityWithCsrfConfig.class, ...})
public class CsrfEnabledIntegrationTest extends CsrfAbstractIntegrationTest {
@Test
public void givenNoCsrf_whenAddFoo_thenForbidden() throws Exception {
mvc.perform(
post("/foos").contentType(MediaType.APPLICATION_JSON)
.content(createFoo())
.with(testUser())
).andExpect(status().isForbidden());
}
@Test
public void givenCsrf_whenAddFoo_thenCreated() throws Exception {
mvc.perform(
post("/foos").contentType(MediaType.APPLICATION_JSON)
.content(createFoo())
.with(testUser()).with(csrf())
).andExpect(status().isCreated());
}
}
Мы видим, как этот тест использует другую конфигурацию безопасности — ту, в которой включена защита CSRF.
Теперь запрос POST просто завершится ошибкой, если не будет включен токен CSRF, что, конечно же, означает, что более ранние атаки больше не возможны.
Кроме того, метод csrf()
в тесте создает RequestPostProcessor
, который автоматически заполняет допустимый токен CSRF в запросе для целей тестирования.
7. Заключение
В этой статье мы обсудили пару CSRF-атак и способы их предотвращения с помощью Spring Security.
Как всегда, код, представленный в этой статье, доступен на GitHub .