Безопасность
CSRF в эпоху SameSite: почему дефолт помог, но не убил атаку
Банк выкатывает эндпоинт «перевести деньги» и чувствует себя спокойно — сессии на 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.
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 — чтобы сбой одного слоя не открыл дверь.