CSRF в эпоху SameSite: почему дефолт помог, но не убил атаку
Браузер сам прикрепляет cookie к кросс-сайтовым запросам, поэтому вредоносная страница может запустить аутентифицированное изменение состояния. SameSite=Lax (дефолт Chrome с 2020) сжал поверхность — но GET-изменения, SameSite=None и брешь top-level навигации держат CSRF живым.
Банк выкатывает эндпоинт «перевести деньги» и чувствует себя спокойно — сессии на cookie, HTTPS везде, логин надёжный. Через месяцы пользователь кликает по ссылке в посте на форуме. Открытая страница тихо авто-сабмитит скрытую форму на POST /transfer с amount=5000&to=attacker. Браузер делает то, что делают браузеры: прикрепляет cookie сессии пользователя к кросс-сайтовому запросу. Сервер видит валидную аутентифицированную сессию и переводит деньги. Пароль не украден, сессия не угнана. Атакующий даже не увидел ответ — ему и не нужно. Побочный эффект уже случился.
Механизм: ambient authority
За десять минут ты поймёшь, почему полностью аутентифицированный запрос может добраться до твоего сервера, не имея ничего общего с намерением пользователя — и какие защиты реально это останавливают.
CSRF существует из-за одного поведения браузера: cookie — это ambient authority (амбиентные, «фоновые» полномочия — браузер прикрепляет их автоматически, без ведома пользователя). Браузер прикрепляет cookie сайта к каждому запросу на origin этого сайта — неважно, кто инициировал запрос. Форма на evil.com, которая постит на bank.com, всё равно получит cookie сессии банка, потому что браузер ключует cookie по адресу назначения, а не по тому, кто начал навигацию. Атакующему не нужно читать или красть cookie (HttpOnly это блокирует и здесь нерелевантен). Ему нужно лишь заставить браузер жертвы отправить запрос. Аутентификация прилетает бесплатно.
В этом весь трюк, и поэтому CSRF иногда зовут «confused deputy» (запутанный заместитель): браузер — это заместитель, действующий с полномочиями пользователя, которого запутали так, что он использует эти полномочия на стороне атакующего. Важно: атакующий не может прочитать ответ — same-origin policy это блокирует. CSRF — это атака на запись, а не на чтение. Она работает на любом эндпоинте, где целью является сам побочный эффект: перевести деньги, сменить email, удалить аккаунт, повысить пользователя до админа.
Куку никто не крадёт — она просто едет вместе с запросом. Анимация ниже показывает, как сессионная кука жертвы авто-прикрепляется к поддельному запросу, путешествуя от страницы атакующего к целевому серверу:
SameSite=Lax: дефолт 2020, который изменил всё
В феврале 2020 Chrome 80 сделал SameSite=Lax дефолтом для любой cookie без явного атрибута. Firefox и Edge последовали. Это был самый сильный удар по классическому CSRF в истории веба. Cookie с Lax не отправляется на кросс-сайтовые сабзапросы — форма на evil.com, постящая на bank.com, больше не получает cookie, поэтому атака выше мертва по умолчанию для POST.
Но Lax — это не Strict, и брешь намеренная. Lax всё же отправляет cookie при top-level навигации безопасными методами — когда пользователь кликает по ссылке и в адресной строке появляется твой сайт, cookie едет с ним (иначе любая входящая ссылка разлогинивала бы тебя). Из этого напрямую следуют два вывода, и оба — живые векторы CSRF.
| Запрос | SameSite=Lax шлёт cookie? | Риск CSRF |
|---|---|---|
Кросс-сайт POST форма / fetch | Нет | Заблокировано по умолчанию — большая победа |
Клик по ссылке (top-level) → кросс-сайт GET | Да | Открыто, если изменение достижимо через GET |
Cookie с SameSite=None; Secure | Да, везде | Вся защита SameSite отключена by design |
POST в первые 120с после установки (Chrome Lax+POST) | Да (только дефолт-Lax) | Окно ~2 минуты после логина |
Почему CSRF не мёртв
Изменения состояния через GET. Если GET /account/delete или GET /transfer?to=x&amount=5000 реально меняет данные, Lax с радостью отправит cookie на top-level навигацию. Атакующему нужен лишь клик жертвы по ссылке (или попадание на страницу с редиректом). Поэтому «никогда не мутируй на GET» — это реальное правило безопасности, а не предпочтение в стиле REST.
SameSite=None. Любой сайт, которому легитимно нужны кросс-сайтовые cookie — встраиваемые виджеты, сторонний SSO, API, потребляемый с другого origin — обязан выставить SameSite=None; Secure, что отключает всю защиту SameSite для этой cookie. В момент, когда ты включаешь кросс-сайт, ты возвращаешься в эпоху до 2020 и тебе нужна настоящая токен-защита.
Окно Lax+POST. Дефолт-Lax в Chrome (не явный SameSite=Lax) держит исключение для совместимости: совсем новая cookie отправляется на top-level кросс-сайтовый POST первые 120 секунд, чтобы не сломать некоторые SSO-редиректы. Это окно CSRF ~2 минуты сразу после установки cookie. Явная установка SameSite=Lax это исключение убирает.
Несогласованное применение. Дефолт-Lax применяет браузер. Старые браузеры, встроенные WebView и некоторые не-Chromium клиенты ведут себя не одинаково, и нельзя считать сервер защищённым клиентским дефолтом. SameSite — слой defense-in-depth, никогда не единственный замок.
▸Почему это работает
Есть тонкое различие, на котором горят: защита отличается между cookie без атрибута SameSite (браузер применяет дефолт-Lax, включающий исключение Lax+POST на 120с) и cookie с явным SameSite=Lax (без исключения, строже). Если ты вообще полагаешься на SameSite — выставляй его явно. Доверять свою безопасность браузерному дефолту значит наследовать его хаки совместимости.
Защиты, которые всё ещё важны
Когда ты аудируешь приложение на сессионных куках, что ты реально ставишь перед эндпоинтом изменения состояния? SameSite сжимает поверхность; он не заменяет токен-защиту ни на одном важном эндпоинте. Два серверных паттерна остаются хребтом:
- Synchronizer token pattern (синхронизатор-токен). Сервер генерирует криптографически случайный токен на сессию (или на запрос), хранит его на сервере и встраивает в каждую форму / шлёт в кастомном заголовке. Кросс-сайтовая страница атакующего не может его прочитать (same-origin policy) и не может угадать. Сервер отклоняет любой меняющий состояние запрос, чей токен не совпал. Stateful, самый сильный, дефолт OWASP для server-rendered приложений.
- Double-submit cookie (двойная отправка cookie). Stateless: сервер кладёт случайное значение в cookie и ждёт то же значение в заголовке / поле формы. Атакующий не может прочитать твою cookie, чтобы скопировать её в заголовок, поэтому значения не совпадут. Дешевле (нет серверного хранения), но слабее — уязвим, если атакующий может писать cookie для твоего домена (например, XSS на сабдомене); поэтому OWASP теперь рекомендует HMAC-подписанный вариант, привязывающий токен к сессии.
Добавь слой defense-in-depth на чувствительные эндпоинты: проверяй, что заголовок Origin (с откатом на Referer) совпадает с твоим origin, отклоняя запросы с чужих origin. А для JSON-API требование кастомного заголовка (например X-Requested-With) плюс непростой content-type заставляют сделать CORS preflight — который кросс-сайтовый fetch атакующего не пройдёт, если твоя CORS-политика это не разрешит. Это частичная мера, а не полная токен-защита, потому что top-level отправки форм и некоторые content-type пропускают preflight.
Вместе эти слои означают, что ни один отдельный сбой не открывает дверь: SameSite режет поверхность, токен даёт позитивное доказательство намерения, Origin/Referer ловит то, что проскальзывает. Без токена SameSite оставляет открытыми GET-эффекты и куки с None; без SameSite каждый POST уязвим до того, как дойдёт до проверки токена.
Server-rendered банковское приложение с cookie-сессиями нужно защитить от CSRF на формах, меняющих состояние. Выбери основную защиту.
Почему HttpOnly НЕ защищает от CSRF?
Твои cookie-сессии — SameSite=Lax. Какой эндпоинт всё ещё открыт для CSRF?
Расставь слои современной защиты от CSRF — от широкого дешёвого фильтра до самого сильного позитивного доказательства:
- 1 Никогда не мутируй состояние на GET — держи все побочные эффекты на POST/PUT/PATCH/DELETE
- 2 Выставляй cookie явно SameSite=Lax (или Strict) — режет кросс-сайтовую поверхность
- 3 Проверяй заголовок Origin (с откатом на Referer) на совпадение с твоим origin
- 4 Требуй synchronizer или HMAC double-submit CSRF-токен на каждое изменение состояния
- 01Объясни, почему CSRF работает, хотя атакующий никогда не видит ответ и не читает cookie сессии.
- 02Chrome с 2020 ставит cookie дефолтом в SameSite=Lax. Почему этого мало само по себе и что добавить?
CSRF работает, потому что cookie — это ambient authority: браузер прикрепляет cookie твоей сессии к любому запросу на твой origin, даже к тому, что начала вредоносная страница, поэтому подделанная запись выполняется с полной аутентификацией, а атакующий ответ так и не читает. Переход Chrome в 2020 на SameSite=Lax дефолтом был крупнейшей одиночной мерой в истории веба — кросс-сайт POST больше не несут cookie — но это не полная защита. Lax всё ещё шлёт cookie на top-level GET-навигации (так что любое изменение на GET открыто), SameSite=None полностью отключает защиту для кросс-сайт сценариев, а дефолт-Lax держит окно Lax+POST ~120 секунд. Поэтому позиция сеньора — defense-in-depth: никогда не мутируй на GET, выставляй SameSite явно, проверяй заголовок Origin/Referer и подкрепляй настоящим CSRF-токеном — синхронизатор-паттерн для server-rendered приложений или HMAC-подписанный double-submit cookie для stateless API — чтобы сбой одного слоя не открыл дверь. Теперь, когда видишь cookie-сессию на эндпоинте с изменением состояния, первый вопрос не «выставлен ли Lax?», а «что произойдёт с запросом без CSRF-токена?»
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.