Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 21 из 25 ~16 мин +20 XP

CSRF: защита cookie-сессий

CSRF защита go-сервиса начинается с вопроса, который стоит задать ещё до первой формы: что произойдёт, если браузер пользователя отправит POST на ваш эндпоинт не с вашей страницы, а с чужой? Ответ неприятный — с cookie-сессией ничего не помешает: браузер сам приложит куку, сервер выполнит перевод денег или смену почты. Разбираем вектор атаки по шагам, флаги SameSite и токен X-CSRF-Token — и места, где оба слоя молча не работают.

Содержание урока · 9 разделов

Ситуация: интернет-банк открыт в первой вкладке, во второй — форум, где под постом спрятана невидимая форма, отправляющая POST на api.bank.example/transfer. Пользователь кликает «показать спойлер», форма уходит, и перевод исполняется — потому что браузер по-честному приложил куку банка к запросу. Это и есть CSRF, cross-site request forgery: атака не ломает криптографию и не крадёт токен, она просто запускает законный запрос из чужого браузера. Важно понять границы: атакующий не видит ответ — Same-Origin Policy запрещает чужой странице читать содержимое ответа с вашего домена, — но побочный эффект уже исполнен, и этого достаточно. Схема с заголовком Authorization из восемнадцатого урока к вектору почти нечувствительна: чужой скрипт не умеет достать токен из памяти фронтенда и не может выставить заголовок. Уязвимы cookie-сессии из шестнадцатого урока — именно их автоматика и делает возможной атаку.

Вектор атаки: чужая форма и ваша кука#

Механика по шагам. Первое: пользователь залогинен в вашем сервисе, кука access лежит в браузере. Второе: пользователь открывает страницу атакующего — пост на форуме, комментарий, письмо в HTML-рассылке. Третье: на этой странице стоит форма с action на ваш эндпоинт и автосабмитом — один вызов form.submit() в скрипте, а то и просто большая невидимая кнопка. Четвёртое: браузер отправляет кросс-сайтовый POST и, если политика SameSite разрешает, прикладывает куку вашего домена. Пятое: сервер видит валидную сессию и исполняет действие, не задавая себе вопрос, кто инициатор. Тонкость, которую часто пропускают: кука отправляется по правилам домена, а не страницы-инициатора — браузеру всё равно, откуда пришла форма. Отсюда и название атаки: подделка кросс-сайтового запроса, когда «подделывается» контекст, а не данные. Сервер честно думает, что действие запросил владелец сессии, хотя решение принимала чужая страница.

Уязвимый хендлер, каким его рисуют в атаке

GO
func handleTransfer(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
                http.Error(w, "только POST", http.StatusMethodNotAllowed)
                return
        }
        to := r.PostFormValue("to")
        amount, err := strconv.ParseInt(r.PostFormValue("amount"), 10, 64)
        if err != nil || to == "" {
                http.Error(w, "битые параметры", http.StatusBadRequest)
                return
        }
        claims, ok := UserFromContext(r.Context())
        if !ok {
                http.Error(w, "нет сессии", http.StatusUnauthorized)
                return
        }
        bank.Transfer(claims.Subject, to, amount) // аутентификация есть, происхождения — нет
        w.WriteHeader(http.StatusOK)
}

Хендлер делает всё правильно по материалам предыдущих уроков: метод проверен, параметры разобраны, сессия вытащена из контекста middleware. Отсутствует ровно одно: проверка того, что запрос инициирован вашим сайтом. Для cookie-сессии этого мало — валидная кука говорит «в браузере залогинен владелец», но не «владелец нажал кнопку сам».

Атрибут SameSite у куки управляет кросс-сайтовыми отправками, и это первый слой защиты. SameSite cookie со значением Lax пропускает куку только в безопасных переходах верхнего уровня — клик по ссылке, редирект с GET, — но не в кросс-сайтовых POST, iframe и fetch. Strict запрещает отправку с чужого сайта вообще: кука уезжает, только если запрос родился на вашем же домене. None разрешает всё, но по современным правилам обязан идти в паре с Secure — без него браузеры куку молча выбросят. Полезный факт эксплуатации: с февраля 2020 года Chrome для кук без явного SameSite применяет Lax по умолчанию, Firefox и Safari подтянулись позже, но рассчитывать на чужой дефолт — плохая стратегия: старые встроенные браузеры и клиенты живут долго. Практическая связка для jwt-сервиса: access-куке — Lax, refresh-куке из девятнадцатого урока — Strict, потому что refresh не нужен ни в каких переходах по ссылкам, только собственные вызовы на /refresh.

Lax закрывает POST: что остаётся вектором#

Почему Lax закрывает большинство векторов: классический CSRF требует POST — автосабмит формы, а POST с чужого сайта Lax отсекает. Остаётся список исключений, и он короткий, но реальный. Первое: GET-эндпоинты с побочным эффектом — отписка по ссылке, подтверждение email с одновременным логином, «выйти со всех устройств» по клику из письма; переход верхнего уровня Lax пропускает, и кука уедет. Второе: None-куки, которые ставят для кросс-доменных фронтендов — там CSRF-токен обязателен всегда. Третье: наследие — SameSite cookie поддержка появилась в основных браузерах в 2017-2020 годах, и если ваша аудитория включает старые WebView Android-приложений, слой CSRF-токена не отменяется. Отдельно помните, что Strict имеет свою цену: пользователь, пришедший по ссылке из письма, окажется разлогиненным — кука не поедет с переходом, и первое действие придётся делать после повторного входа. Для refresh-куки это приемлемо, для access-куки витрины — спорно.

Ставим флаги: Lax для access, Strict для refresh

GO
func accessCookie(token string) *http.Cookie {
        return &http.Cookie{
                Name: "access", Value: token, Path: "/",
                HttpOnly: true, Secure: true,
                SameSite: http.SameSiteLaxMode,
                MaxAge:   int((15 * time.Minute).Seconds()),
        }
}

func refreshCookie(token string) *http.Cookie {
        return &http.Cookie{
                Name: "refresh", Value: token, Path: "/api/auth/refresh",
                HttpOnly: true, Secure: true,
                SameSite: http.SameSiteStrictMode,
                MaxAge:   int((30 * 24 * time.Hour).Seconds()),
        }
}

Два помощника держат флаги в одном месте, как договаривались в уроке про жизненный цикл: расползание cookie-конфигурации по файлам — способ получить куку без HttpOnly в одном хендлере и None в другом. Access получает Lax: сессия должна переживать переход по ссылке с внешней страницы. Refresh получает Strict и узкий Path — его работа выполняется только собственными вызовами приложения.

CSRF-токен: второй слой csrf защита go#

SameSite — слой браузера, и положиться только на него нельзя: у токена должен быть второй, серверный слой. csrf защита go строится на идее простого неравенства: атакующий может отправить запрос от имени браузера жертвы, но не может прочитать данные вашего домена — Same-Origin Policy не пускает. Значит, достаточно требовать в каждом изменяющем запросе секрет, который чужая страница достать не умеет. Для cookie-сессий стандарт де-факто — двойная отправка: сервер ставит отдельную куку csrf с токеном без HttpOnly, фронтенд-скрипт читает её и прикладывает значение заголовком X-CSRF-Token; middleware сравнивает значение куки и заголовка. Чужой сайт отправит куку автоматически — но заголовок выставить не сможет: чтобы его узнать, надо прочитать куку вашего домена, а это запрещено. Второй подход — синхронизатор: токен хранится в сессии на сервере и вставляется в формы скрытым полем; он нужен классическим серверным шаблонам, где JS рядом нет.

Middleware X-CSRF-Token с двойной отправкой

GO
func issueCSRFCookie(w http.ResponseWriter) string {
        tok := make([]byte, 32)
        if _, err := rand.Read(tok); err != nil {
                panic(err)
        }
        val := base64.RawURLEncoding.EncodeToString(tok)
        http.SetCookie(w, &http.Cookie{
                Name: "csrf", Value: val, Path: "/",
                Secure: true, SameSite: http.SameSiteLaxMode,
        }) // без HttpOnly: значение должен читать JS фронтенда
        return val
}

func RequireCSRF(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                c, err := r.Cookie("csrf")
                hdr := r.Header.Get("X-CSRF-Token")
                if err != nil || hdr == "" || subtle.ConstantTimeCompare([]byte(c.Value), []byte(hdr)) != 1 {
                        http.Error(w, "csrf-токен не совпал", http.StatusForbidden)
                        return
                }
                next.ServeHTTP(w, r)
        })
}

Кука csrf ставится без HttpOnly сознательно: её клиент — JavaScript, который кладёт значение в заголовок на каждый изменяющий запрос. Сравнение идёт через subtle.ConstantTimeCompare — постоянное время, чтобы не подсказывать перебором длину совпадения. Middleware навешивается только на изменяющие маршруты: GET-запросы токен не несут, и требовать его там — способ сломать все переходы по ссылкам.

СлойЧто закрываетЧего не закрывает
SameSite Laxкросс-сайтовые POST, iframe, fetchпереходы верхнего уровня по GET-ссылкам
SameSite Strictвсе кросс-сайтовые запросыудобство: вход по внешней ссылке без куки
CSRF-токен (double submit)любой запрос без валидного заголовкасценарии, где XSS уже читает страницу
Bearer-схема с токеном в памятиCSRF по определению нет кукиXSS: скрипт вынесет токен заголовком
Правило «состояние — только POST»GET-векторы при Laxничего, если дисциплина нарушена

Границы double submit: субдомены и XSS#

У двойной отправки есть граница, о которой стоит знать до инцидента: поддоменная инъекция. Если атакующий контролирует любой субдомен вашего сайта — забытый стейджинг, пользовательский контент на blog.example.com, — он может поставить куку на родительский домен, и double submit перестанет быть надёжным: чужая кука тоже сравнится с чужим заголовком. Лечение — привязка CSRF-токена к сессии: токен не просто совпадает с кукой, но и совпадает со значением, выпущенным для конкретной сессии, тогда чужая подмена отсекается. Вторая граница — XSS: если скрипт атакующего уже исполняется на вашей странице, он прочитает csrf-куку так же, как ваш собственный фронтенд, и CSRF-слои тут бессильны — это территория CSP и гигиены зависимостей. Спорный вопрос, который регулярно всплывает в ревью: нужно ли всё это внутренней админке на десять человек. Практичный ответ: SameSite Lax и POST-only — да, обязательны; полноценный CSRF-токен — с момента, когда админка смотрит наружу хотя бы ссылками.

Проверка: что пропускает Lax

Кука поставлена с SameSite=Lax. Какой кросс-сайтовый запрос поедет с ней?

Проверка: где живёт CSRF-токен

При двойной отправке где находится CSRF-токен?

Форма с чужой страницы

Прочитайте код и выберите, что он выведет:

// Кука: access, SameSite=Lax
// Страница evil.example автосабмитом отправляет:
// <form method="POST" action="https://api.bank.local/transfer">
//   <input name="to" value="attacker"><input name="amount" value="100">
// </form>
// Что вернёт /transfer?

Соберите защиту в Set-Cookie и middleware

Допишите флаги куки и проверку заголовка: политика для access-куки, сравнение и статус отказа.

http.SetCookie(w, &http.Cookie{ Name: "access", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteMode, }) c, err := r.Cookie("csrf") hdr := r.Header.Get("X-CSRF-Token") if err != nil || subtle.ConstantTimeCompare([]byte(c.Value), []byte(hdr)) != 1 { http.Error(w, "csrf-токен не совпал", http.Status) return }

Соберите проверку CSRF-запроса

Расставьте шаги изменяющего запроса с двойной отправкой в правильном порядке.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: CSRF

Термин

CSRF

нажмите, чтобы перевернуть
Определение

Кросс-сайтовая подделка запроса: чужая страница запускает запрос к вашему API из браузера жертвы, и кука прикладывается автоматически. Ответ атакующий не читает — только побочный эффект.

1 / 6

Cookie-сессия теперь защищена двумя слоями: браузерным SameSite и серверным токеном. Дальше по курсу: куда едут эти сессии в распределённой системе — двадцать второй урок про микросервисы; почему payload можно читать, но нельзя править — двадцать третий; финальная сборка всего курса — итоговый auth-сервис. Фундамент, на котором стоит урок: флаги и выдача куки — шестнадцатый урок, сравнение cookie-сессий с сессиями на Redis — урок про сессии в go-redis, а полный цикл логина и логаута — девятнадцатый. Карта всех тем — на странице jwt-go.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • CSRF — атака контекстом: чужая страница запускает запрос из браузера жертвы, кука прикладывается сама.
  • SameSite Lax отсекает кросс-сайтовые POST и остаётся дефолтом для access-куки; Strict берём для refresh.
  • Второй слой — CSRF-токен: двойная отправка кука плюс X-CSRF-Token и сравнение в постоянном времени.
  • GET с побочным эффектом — дыра, которую Lax не закрывает: меняет состояние — только POST.
  • Границы double submit: субдоменная инъекция и уже случившийся XSS — там нужны привязка к сессии и CSP.
  • Bearer-схема с токеном в памяти CSRF не боится, зато боится XSS — это другая сторона того же компромисса.

Домашнее задание

Соберите стенд из двух доменов на локальном Go: сервис с /transfer и страницу-атакующего с автосабмитом. Прогоните три конфигурации: без SameSite, с Lax и со Strict — и запишите, в каких уходит POST-форма. Затем добавьте middleware X-CSRF-Token с двойной отправкой и проверьте, что легитимный фронтенд проходит, а страница-атакующий ловит 403 даже без SameSite. В конце найдите в собственном коде все GET-маршруты, которые меняют состояние, и переведите их на POST.

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · CSRF: защита cookie-сессий)

Вопросы и ответы по уроку

Мы храним токен в localStorage и шлём через Authorization. CSRF всё ещё нужен?

Сам CSRF-слой — нет: куки нет, прикладывать нечего. Но платить приходится в другой валюте: любой XSS читает localStorage и уносит токен навсегда, тогда как при cookie-сессии он может лишь слать запросы. Выбор между схемами разобран в уроке про хранение токена.

Достаточно ли проверки заголовка Origin или Referer?

Это полезный третий слой, но не замена: Origin может отсутствовать в некоторых переходах и старых клиентах, а фильтры по списку доменов регулярно ломаются при добавлении нового окружения. Стандартная связка — SameSite плюс CSRF-токен, Origin — бонусный лог-сигнал.

Почему CSRF-кука без HttpOnly — это безопасно?

Потому что её компрометация не даёт ничего сверх того, что XSS и так получил бы: скрипт с заражённой страницы может слать запросы напрямую. HttpOnly куке нужен access-токен, csrf-куке — нет; её единственная работа — быть прочитанной легитимным JS и приложенной заголовком.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Telegram25 уроков~12 чновичок25 уроков уже доступны

Telegram-боты на Go: от эха до полноценного сервиса

Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.

  • Создавать бота через BotFather и хранить токен безопасно
  • Получать обновления через long polling и webhook
  • Отвечать сообщениями, командами и клавиатурами
  • Строить многошаговые диалоги с сохранением состояния