CSRF: Векторы атаки и механизмы защиты
Cross-Site Request Forgery (CSRF) — это вид атаки, при которой злоумышленник обманом заставляет браузер аутентифицированного пользователя отправить нежелательный HTTP-запрос к веб-приложению. В отличие от XSS, целью CSRF является не кража данных, а выполнение действий от имени пользователя.
Фундаментальная причина уязвимости заключается в том, что браузеры автоматически прикрепляют аутентификационные данные (прежде всего Cookies) к запросам на соответствующие домены, независимо от того, с какого ресурса был инициирован запрос.
Векторы атаки #
Для эксплуатации этой уязвимости злоумышленник создаёт страницу, которая незаметно отправляет запрос на уязвимый сервер. Этой странице не нужно быть подделкой уязвимого сайта – это может быть статья, новость – что угодно. Главное – заставить пользователя посетить её, атака будет осуществлена незаметно, и может не вызывать никаких подозрений.
Рассмотрим способы реализации этой атаки.
Атака через HTML форму #
Самый простой и распространённый вариант – обычная HTML форма.
Например, на сайте нашего ЦУП есть форма запуска ракеты, доступная только руководителю полётов:
1<!-- Форма на сайте mission-control.internal -->
2<form
3 action="/launch"
4 method="POST"
5>
6 <select name="launchpad">
7 <option value="lc-39a">LC-39A</option>
8 <option value="lc-39b">LC-39B</option>
9 <option value="slc-40">SLC-40</option>
10 <option value="slc-41">SLC-41</option>
11 </select>
12 <button type="submit" class="big-red-button">
13 ПУСК
14 </button>
15</form>
Форма обрабатывается бэкендом и инициирует запуск ракеты на указанной площадке:
1func launchHandler(w http.ResponseWriter, r *http.Request) {
2 // Проверка сессии
3 sessionCookie, _ := r.Cookie("session_id")
4 session := getSession(sessionCookie)
5 if session == nil || session.Role != "FlightDirector" {
6 http.Error(w, "Доступ только для руководителя полетов", http.StatusUnauthorized)
7 return
8 }
9 // Чтение данных из формы
10 launchpad := r.FormValue("launchpad")
11 // Выполнение критического действия
12 spaceAPI.InitiateLaunch(launchpad)
13 fmt.Fprintf(w, "Ракета на площадке %s запущена!", launchpad)
14}
На первый взгляд метод может показаться безопасным – в нём есть проверка сессии и роли.
Но в ней никакой проверки откуда пришёл запрос. Злоумышленник может создать страницу с интересной информацией и отправить ссылку на неё ничего не подозревающему руководителю полётов:
1<html>
2 <head>
3 <title>Фотографии с ежегодной встречи рептилоидов</title>
4 </head>
5 <body>
6 <img src="/elon.jpg" />
7 <img src="/mark.jpg" />
8
9 <!-- скрытая форма на сайте onlyreptiloids.tld -->
10 <form
11 action="https://mission-control.internal/launch"
12 method="POST"
13 id="attack-form"
14 >
15 <input type="hidden" name="launchpad" value="slc-40" />
16 </form>
17 <script>
18 // Автоматическая отправка формы
19 document.getElementById("attack-form").submit();
20 </script>
21 </body>
22</html>
Жертве достаточно зайти на эту страницу, чтобы форма была автоматически отправлена на атакуемый сайт.
Поскольку Content-Type этой формы – application/x-www-form-urlencoded, браузер без предварительных проверок (CORS preflight) отправит запрос вместе с сессионной кукой.
Атака через GET-запрос #
Стоит помнить, что GET-запросы по определению должны быть идемпотентными – они не должны приводить к изменениям состояния системы, и в контексте CSRF такие запросы по-умолчанию считаются безопасными, защитные механизмы для них не обязательны. Но на практике разработчики часто пренебрегают этим правилом, либо в результате ошибки допускают использование метода GET вместо POST.
Мы допустили такую ошибку в примере выше с реализацией launchHandler – не проверили http-метод и использовали r.FormValue, который извлекает данные не только из формы, но и из query-параметров.
В результате злоумышленнику даже не обязательно отправлять форму – достаточно сформировать ссылку с указанием этих параметров, и, например, указать её в качестве src у картинки:
1<img src="/elon.jpg" />
2<img src="/mark.jpg" />
3<!-- Опасная картинка: -->
4<img src="https://mission-control.internal/launch?launchpad=slc-40" />
Для браузера нет разницы между запросом картинки или вызовом метода API, как только руководитель полётов откроет эту страницу, браузер попытается загрузить "картинку" вместе с куками сессии, а небезопасный метод бэкенда, не отличая GET от POST, выполнит метод.
Атака через XHR (XMLHttpRequest и fetch) #
Этот метод может применяться когда необходимо выполнить сложный запрос (например к JSON-RPC API), который нельзя реализовать через обычную форму.
1// скрипт на сайте с рептилоидами
2fetch("https://mission-control.internal/api/adjust-orbit", {
3 method: "POST",
4 headers: {
5 "Content-Type": "text/plain" // Обход preflight-проверки браузером
6 },
7 body: JSON.stringify({
8 "thruster_id": "main-engine",
9 "action": "burn",
10 "duration_sec": 600
11 }),
12 credentials: "include" // Принудительная отправка кук вместе с запросом
13});
В современных браузерах такие атаки практически полностью блокируются механизмом CORS и политикой Same-Origin, поэтому для его реализации на сервере должны быть допущены специфические ошибки конфигурации:
- Небезопасная конфигурация CORS: разрешены запросы с произвольных доменов и разрешена передача учётных данных
Access-Control-Allow-Credentials: true). - Ослабленная защита Cookies: сессионные куки с атрибутом
SameSite=None, либо пользователь использует устаревший браузер.
Методы защиты #
Для защиты от атак CSRF нам необходимо убедиться, что запрос пришёл из доверенного интерфейса.
Внимание!
Примеры кода приведены только для демонстрации идеи. В целях повышения читаемости в них может отсутствовать валидация, обработка ошибок и допущены другие упрощения. Пожалуйста, не используйте их "как есть" в своих проектах. Более подробные и точные примеры можно найти в связанных статьях, посвящённых этим темам.
Конфигурация параметров Cookie (SameSite) #
Современные браузеры поддерживают атрибут SameSite для заголовка Set-Cookie, который ограничивает передачу кук при кросс-доменных запросах:
- Strict: куки передаются только в рамках одного и того же сайта. Это эффективно для предотвращения большинства атак, но при этом может доставить неудобства пользователю – при переходе на сайт по внешней ссылке (например, из вашего же дашборда на другом домене), пользователь окажется разлогинен.
- Lax: куки не передаются при кросс-доменных POST-запросах, но разрешены при обычных переходах по ссылкам (GET). Это значение используется в Go по умолчанию в большинстве современных фреймворков и библиотек.
1// Устанавливаем куку с правильными атрибутами
2http.SetCookie(w, &http.Cookie{
3 Name: "session_id",
4 Value: sessionId,
5 Path: "/",
6 HttpOnly: true, // Защита от XSS
7 Secure: true, // Только через HTTPS
8 // Защита от CSRF на уровне браузера:
9 SameSite: http.SameSiteLaxMode,
10})
Стоит помнить, что это относительно новые атрибуты, старые браузеры могут их игнорировать, поэтому обязательно стоит применять и другие способы защиты.
Верификация заголовков Origin и Referer #
Сервер может проверять заголовки Origin или Referer, чтобы убедиться, что запрос пришел с доверенного домена.
1// Пример middleware для проверки Origin
2func OriginCheckMiddleware(next http.Handler) http.Handler {
3 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
4 // Проверяем только мутирующие методы
5 if r.Method == http.MethodPost || r.Method == http.MethodPut {
6 source := req.Header.Get("Origin")
7 // Если Origin пустой, пробуем Referer
8 if source == "" {
9 source = req.Header.Get("Referer")
10 }
11 sourceUrl, _ := url.Parse(source)
12 // Проверяем, совпадает ли Host и Origin/Referer
13 if sourceUrl.Host != req.Host {
14 http.Error(w, "Попытка межсайтового запуска отклонена", http.StatusForbidden)
15 return
16 }
17 }
18 // Обрабатываем запрос дальше
19 next.ServeHTTP(w, req)
20 })
21}
Это неплохой метод защиты, но он тоже не лишён недостатков:
- Хотя современные браузеры обязаны присылать один из этих заголовков при POST-запросах, и само их отсутствие уже может считаться поводом для отклонения запроса, они могут вырезаться прокси-сервером.
- При наличии сложной инфраструктуры с несколькими доменами, поддержка списка доверенных доменов может быть трудоёмкой.
Верификация заголовков Sec-Fetch-* #
Помимо Origin и Referer, стоит анализировать заголовки Sec-Fetch-*, которые подставляются современными браузерами и их невозможно подделать с помощью скриптов в браузере.
Так, заголовок Sec-Fetch-Site может принимать значения same-origin, same-site, cross-site и none.
same-origin– запрос сделан с того же домена, протокола и порта – самый безопасный уровень.same-site– запрос сделан с поддомена того же сайта (например сapi.mission-control.internalнаmission-control.internal).cross-site– запрос с любого другого сайта.none– запрос инициирован самим пользователем (ввод в адресную строку, из закладок, и т.п.).
1// Пример middleware для проверки Sec-Fetch-Site
2func FetchMetadataMiddleware(next http.Handler) http.Handler {
3 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4 // Проверяем только мутирующие методы
5 if r.Method == http.MethodPost || r.Method == http.MethodPut {
6 site := r.Header.Get("Sec-Fetch-Site")
7
8 // Разрешаем только запросы с нашего домена или прямые заходы
9 if site != "same-origin" && site != "same-site" && site != "none" {
10 http.Error(w, "Запрос заблокирован политикой Fetch Metadata", http.StatusForbidden)
11 return
12 }
13 }
14
15 next.ServeHTTP(w, r)
16 })
17}
Помимо этого полезным может быть анализировать заголовки Sec-Fetch-Mode (как сделан запрос – ссылка, форма, скрипт), Sec-Fetch-Dest (что запрашивают – документ, картинка, и т.п.) и Sec-Fetch-User (участие пользователя – клик или автоматически).
Это прекрасный способ гибко настраивать допустимые способы выполнения запроса, но не стоит забывать, что так же как и SameSite, эти заголовки могут не отправляться старыми браузерами.
Криптографические токены (Challenge-Response) #
Хотя все предыдущие методы позволяют повысить безопасность, но они не лишены недостатков, и должны использоваться лишь как дополнительные методы защиты.
Наиболее надежный и универсальный способ – добавлять в каждый запрос секретное уникальное значение (CSRF-токен).
В зависимости от того, является ли ваша система stateful (с сессией) или stateless (без сессий), могут применяться два различных подхода:
- Synchronizer Token Pattern: Сервер генерирует токен, сохраняет его в сессии пользователя и вставляет в HTML-форму. При получении запроса сервер сравнивает токен из формы с токеном в сессии.
- Double Submit Cookie: Токен отправляется и в куке, и в теле запроса. Сервер проверяет их совпадение, не сохраняя состояние на своей стороне (stateless).
В следующих статьях мы подробно рассмотрим каждый подход и детали их реализации.