Ситуация: интернет-банк открыт в первой вкладке, во второй — форум, где под постом спрятана невидимая форма, отправляющая POST на api.bank.example/transfer. Пользователь кликает «показать спойлер», форма уходит, и перевод исполняется — потому что браузер по-честному приложил куку банка к запросу. Это и есть CSRF, cross-site request forgery: атака не ломает криптографию и не крадёт токен, она просто запускает законный запрос из чужого браузера. Важно понять границы: атакующий не видит ответ — Same-Origin Policy запрещает чужой странице читать содержимое ответа с вашего домена, — но побочный эффект уже исполнен, и этого достаточно. Схема с заголовком Authorization из восемнадцатого урока к вектору почти нечувствительна: чужой скрипт не умеет достать токен из памяти фронтенда и не может выставить заголовок. Уязвимы cookie-сессии из шестнадцатого урока — именно их автоматика и делает возможной атаку.
Вектор атаки: чужая форма и ваша кука#
Механика по шагам. Первое: пользователь залогинен в вашем сервисе, кука access лежит в браузере. Второе: пользователь открывает страницу атакующего — пост на форуме, комментарий, письмо в HTML-рассылке. Третье: на этой странице стоит форма с action на ваш эндпоинт и автосабмитом — один вызов form.submit() в скрипте, а то и просто большая невидимая кнопка. Четвёртое: браузер отправляет кросс-сайтовый POST и, если политика SameSite разрешает, прикладывает куку вашего домена. Пятое: сервер видит валидную сессию и исполняет действие, не задавая себе вопрос, кто инициатор. Тонкость, которую часто пропускают: кука отправляется по правилам домена, а не страницы-инициатора — браузеру всё равно, откуда пришла форма. Отсюда и название атаки: подделка кросс-сайтового запроса, когда «подделывается» контекст, а не данные. Сервер честно думает, что действие запросил владелец сессии, хотя решение принимала чужая страница.
Уязвимый хендлер, каким его рисуют в атаке
GOfunc 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 cookie: Lax, Strict и None#
Атрибут 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
GOfunc 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 — его работа выполняется только собственными вызовами приложения.
Правило «меняет состояние — только POST» ломается чаще, чем кажется: /api/unsubscribe?email=, /logout, /session/revoke-all в GET встречаются в продакшене постоянно — потому что удобно поставить ссылку в письмо. С SameSite Lax такая ссылка — рабочий вектор: клик с чужой страницы — это переход верхнего уровня, кука приложится, эндпоинт исполнится. Лечится жёстко: любые изменяющие операции переводятся на POST, а GET оставляет только чтение. Проверяйте маршруты поиском по хендлерам: каждый GET, который пишет в базу или меняет сессию, — кандидат в уязвимости.
CSRF-токен: второй слой csrf защита go#
SameSite — слой браузера, и положиться только на него нельзя: у токена должен быть второй, серверный слой. csrf защита go строится на идее простого неравенства: атакующий может отправить запрос от имени браузера жертвы, но не может прочитать данные вашего домена — Same-Origin Policy не пускает. Значит, достаточно требовать в каждом изменяющем запросе секрет, который чужая страница достать не умеет. Для cookie-сессий стандарт де-факто — двойная отправка: сервер ставит отдельную куку csrf с токеном без HttpOnly, фронтенд-скрипт читает её и прикладывает значение заголовком X-CSRF-Token; middleware сравнивает значение куки и заголовка. Чужой сайт отправит куку автоматически — но заголовок выставить не сможет: чтобы его узнать, надо прочитать куку вашего домена, а это запрещено. Второй подход — синхронизатор: токен хранится в сессии на сервере и вставляется в формы скрытым полем; он нужен классическим серверным шаблонам, где JS рядом нет.
Middleware X-CSRF-Token с двойной отправкой
GOfunc 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-запросы токен не несут, и требовать его там — способ сломать все переходы по ссылкам.
Слои защиты cookie-сессии#
| Слой | Что закрывает | Чего не закрывает |
|---|---|---|
| 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-токен — с момента, когда админка смотрит наружу хотя бы ссылками.
Понадобится локальный Go и два имени в /etc/hosts — bank.local и evil.local на 127.0.0.1: поднимите сервис с Lax-кукой и страницу-атакующего с автосабмитом, и перевод уйдёт; затем добавьте SameSite Strict и убедитесь, что POST перестал доезжать. Браузерная песочница этого курса HTTP-запросов и кук не умеет — она исполняет только fmt, strings, strconv, math, sort, errors и time, поэтому эксперимент с доменами честно делается локально. Файл куки curl с флагом --cookie покажет, какие флаги реально приехали в Set-Cookie.
Проверка: что пропускает 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-куки, сравнение и статус отказа.
Соберите проверку CSRF-запроса
Расставьте шаги изменяющего запроса с двойной отправкой в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: CSRF
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.
Личные заметкиПусто
Вопросы и ответы по уроку
Мы храним токен в localStorage и шлём через Authorization. CSRF всё ещё нужен?
Сам CSRF-слой — нет: куки нет, прикладывать нечего. Но платить приходится в другой валюте: любой XSS читает localStorage и уносит токен навсегда, тогда как при cookie-сессии он может лишь слать запросы. Выбор между схемами разобран в уроке про хранение токена.
Достаточно ли проверки заголовка Origin или Referer?
Это полезный третий слой, но не замена: Origin может отсутствовать в некоторых переходах и старых клиентах, а фильтры по списку доменов регулярно ломаются при добавлении нового окружения. Стандартная связка — SameSite плюс CSRF-токен, Origin — бонусный лог-сигнал.
Почему CSRF-кука без HttpOnly — это безопасно?
Потому что её компрометация не даёт ничего сверх того, что XSS и так получил бы: скрипт с заражённой страницы может слать запросы напрямую. HttpOnly куке нужен access-токен, csrf-куке — нет; её единственная работа — быть прочитанной легитимным JS и приложенной заголовком.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния