Безопасность веба и API
Чтение JavaScript с ИИ для поиска endpoints
Фронтенд на JavaScript перечисляет API, с которым говорит приложение. Модель ИИ читает бандлы и выносит endpoints и параметры, а вы проверяете каждый.
Cross-site request forgery использует cookie вашего браузера против вас. Защиты от сильной к слабой и проверка SameSite в лаборатории на Flask.
Cross-site request forgery (CSRF) даёт странице одного сайта заставить ваш браузер отправить запрос, меняющий состояние, на другой сайт, где вы авторизованы. Браузер сам прикрепляет cookie сессии, поэтому тот сайт считает запрос вашим. Защиты такие: секрет на сессию, который злоумышленник не может прочитать, атрибут cookie SameSite, проверка Origin и правило, что GET-запросы никогда не меняют состояние. Статья разбирает каждую из них, а затем запускает небольшую лабораторию на Flask, где движение cookie видно своими глазами.
Веб-сессия обычно живёт в cookie. После входа на bank.example браузер сохраняет cookie для этого хоста и отправляет её с каждым запросом к нему. Сервер читает cookie, находит вашу сессию и выполняет действие.
Браузер не проверяет, откуда пришёл запрос. Если на странице evil.example есть форма, отправляющая POST на https://bank.example/transfer, браузер отправит этот POST и прикрепит к нему cookie bank.example. Банк видит действительную сессию и переводит деньги. Злоумышленник ни разу не видит вашу cookie и не читает ответ. Для атаки нужно только, чтобы сервер выполнил действие.
CWE-352 описывает эту слабость так: приложение выполняет действие, меняющее состояние, от имени пользователя, не проверяя, что пользователь сам решил отправить запрос. Исправление в том, чтобы сервер умел отличать запрос пользователя от запроса, подделанного сторонней страницей.
Этому риску подвержено любое приложение, которое хранит сессии в cookie и меняет данные по запросу. Опаснее всего действия, меняющие учётные данные или настройки: смена пароля, смена адреса email, новый API-ключ, новый способ оплаты, правило пересылки. Поддельная смена email часто становится первым шагом к захвату аккаунта, потому что затем злоумышленник может сбросить пароль через новый адрес.
Сессии в cookie используются по умолчанию во многих фреймворках, и браузер отправляет cookie независимо от того, подумал об этом разработчик или нет. Поэтому CSRF до сих пор попадает в списки OWASP и CWE.
bank.example. Браузер сохраняет cookie сессии для этого хоста.https://bank.example/transfer со значениями, которые выбрал злоумышленник. Она может отправиться при загрузке, так что жертве даже не нужно ничего нажимать.bank.example.Обратите внимание, чего злоумышленнику не понадобилось. Ему не пришлось красть cookie, подбирать пароль или читать хотя бы один байт ответа банка. Всю работу сделал браузер.
GET-запрос можно вызвать без всякой формы. Тег <img src="https://bank.example/delete?id=7"> заставляет браузер отправить GET-запрос, как только страница отрисуется. Предзагрузка, превью ссылок и краулеры тоже могут отправлять GET-запросы. Если GET-маршрут меняет данные, для CSRF форма вообще не нужна.
Правило простое: GET и HEAD читают данные и никогда их не меняют. Изменения идут через POST, PUT, PATCH или DELETE. Само по себе это правило защитой не считается, потому что POST всё равно можно подделать, но все остальные защиты работают лучше, когда оно соблюдается.
Сервер создаёт случайный токен для каждой сессии, вставляет его в каждую форму как скрытое поле и проверяет при каждом запросе, меняющем состояние. Запрос без правильного токена отклоняется.
Токен работает, потому что страница злоумышленника не может его прочитать. Same-origin policy не даёт evil.example читать HTML, который вернул bank.example, поэтому злоумышленник не может скопировать скрытое поле в поддельную форму. Поддельный запрос приходит с cookie, но без токена.
Токен должен быть непредсказуемым (используйте криптографический генератор случайных чисел), привязанным к сессии пользователя и проверяться при каждом небезопасном методе, включая эндпоинты, которые вызывает ваш JavaScript. Большинство фреймворков поставляется с такой защитой. По этой причине OWASP cheat sheet ставит защиты на основе токенов на первое место.
Double-submit cookie — более слабый вариант. Токен кладут и в cookie, и в поле запроса, а сервер их сравнивает. Злоумышленник, который может устанавливать cookie на вашем домене, например с соседнего поддомена, способен эту защиту обойти. Если можете хранить состояние на сервере, выбирайте синхронизирующий токен.
SameSite сообщает браузеру, когда прикреплять cookie к запросу, начатому на другом сайте. У атрибута три значения.
Secure, так что cookie ходит только по HTTPS. Используйте None, только когда настоящему межсайтовому сценарию нужна cookie, например встроенному стороннему виджету.MDN описывает атрибут и его значения в справке по Set-Cookie. Черновик RFC 6265bis определяет «сайт» через регистрируемый домен (публичный суффикс плюс метка слева от него, так что для www.site.example это site.example). Номера портов не учитываются. Поддомены считаются тем же сайтом. Позже в лаборатории это пригодится, потому что localhost и 127.0.0.1 — разные хосты и, следовательно, разные сайты.
SameSite надёжен, но закрывает не всё. Соседний поддомен вроде forum.bank.example находится на одном сайте с bank.example, поэтому атаку оттуда Lax не остановит. Старые браузеры, которые игнорируют атрибут, отправляют cookie куда угодно. И Lax всё равно отправляет cookie при GET-переходах верхнего уровня, поэтому правило про GET остаётся в силе. Считайте SameSite одним из слоёв.
Браузеры отправляют заголовок Origin с POST-запросами и с большинством межсайтовых запросов. В заголовке указаны схема, хост и порт страницы, начавшей запрос, поэтому сервер может сравнить его со своим origin. Sec-Fetch-Site даёт похожий сигнал, со значениями вроде same-origin, same-site и cross-site.
Проверка простая. Отклоняйте запросы, меняющие состояние, когда Origin присутствует и не совпадает с вашим origin. Если Origin нет, смотрите Referer. Если на запросе, меняющем состояние, нет обоих, отклоняйте его. Разбирайте заголовок как URL и сравнивайте origin целиком. Проверка по подстроке не работает: Origin: https://bank.example.evil.test содержит ваш домен, но принадлежит кому-то другому.
Проверки origin ловят явные межсайтовые запросы, но зависят от заголовков, которые некоторые клиенты не отправляют, а настройки приватности могут убрать. Используйте их как фильтр и оставьте токен для случаев, которые заголовки упускают.
Это правило, и одним запросом его не протестируешь. Соблюдайте его, потому что с ним каждая другая защита покрывает больше. Маршрут, который меняет состояние только на POST, оставляет злоумышленнику форму для подделки, тег изображения ему уже не подбросить.
Понадобятся Python 3.12 и три окна терминала. Для этой статьи всё запускалось на Python 3.12.10 в Windows 11 с Git Bash. В лаборатории работают одно приложение Flask на 127.0.0.1:5000 и обычная статическая страница на localhost:8001. Именно второй origin делает запрос межсайтовым.
Создайте виртуальное окружение и установите Flask. В Linux и macOS активируйте его командой . .venv/bin/activate. В Windows с Git Bash скрипт активации лежит в .venv/Scripts/activate.
python3 -m venv .venv && . .venv/bin/activate && pip install flaskВ этом запуске установились Flask 3.1.3 и Werkzeug 3.1.9.
Сохраните это как app.py. Маршрут /login ставит cookie сессии с SameSite=Lax и HttpOnly. Маршрут /transfer ничего не меняет. Он только печатает, пришла ли cookie, и два заголовка запроса, которые помогают прочитать результат.
import os
from flask import Flask, make_response, request
app = Flask(__name__)
# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"
@app.get("/login")
def login():
resp = make_response("logged in\n")
resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
return resp
@app.post("/transfer")
def transfer():
arrived = request.cookies.get("session") == "lab"
print(
f"POST /transfer cookie_arrived={arrived} "
f"origin={request.headers.get('Origin')} "
f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
flush=True,
)
return f"cookie_arrived={arrived}\n"Приложение пока ничего не проверяет, и сделано это намеренно. Сначала нужно увидеть, что отправляет браузер, и только потом добавлять защиту.
В первом терминале:
flask --app app run --host 127.0.0.1 --port 5000Во втором терминале запросите маршрут входа и прочитайте заголовок:
curl -i http://127.0.0.1:5000/loginВывод этого запуска, без строки Date:
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close
logged inЗдесь главное — строка Set-Cookie. HttpOnly скрывает cookie от JavaScript на странице. Браузер всё равно её отправляет, так что HttpOnly защитой от CSRF не служит. Атрибут, который меняет то, что браузер отправляет между сайтами, называется SameSite=Lax.
Прежде чем подключать браузер, отправьте запросы сами, чтобы знать, что записывает сервер:
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transferОтветы этого запуска, по одной строке на команду:
cookie_arrived=False
cookie_arrived=True
cookie_arrived=TrueСтроки, которые терминал Flask напечатал для этих трёх запросов:
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-origincurl не применяет SameSite. Он отправляет всё, что вы передали через -b. Эти строки доказывают, что маршрут читает cookie и заголовки. Что отправит браузер, они не доказывают. Это следующий шаг.
Создайте папку attacker и сохраните это как attacker/index.html:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
<input type="hidden" name="amount" value="100">
<button type="submit">Send transfer</button>
</form>
</body>
</html>В третьем терминале запустите статический сервер из папки, в которой лежит attacker/:
python3 -m http.server 8001 --bind 127.0.0.1В этом запуске запрос к статическому серверу вернул HTTP 200. Затем страница открывается по адресу http://localhost:8001/attacker/. Обратите внимание на имя хоста. localhost и 127.0.0.1 — разные хосты, поэтому форма с localhost:8001 и хост cookie находятся на разных сайтах. Это и есть нужная межсайтовая конфигурация.
Сначала откройте в браузере http://127.0.0.1:5000/login, чтобы cookie сохранилась для 127.0.0.1 в этом профиле браузера. Затем откройте http://localhost:8001/attacker/ в том же браузере и нажмите Send transfer. Прочитайте терминал Flask.
С SameSite=Lax межсайтовый POST из формы не должен нести cookie. Ожидаемая строка журнала: cookie_arrived=False. Браузер не включает cookie, потому что Lax не отправляет её на межсайтовых POST-запросах. Для этой статьи шаг в браузере не выполнялся, поэтому сверьте строку журнала с этим ожиданием сами.
Теперь перезапустите приложение с None:
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000Прочитайте заголовок на новом сервере:
curl -i http://127.0.0.1:5000/loginВывод этого запуска:
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=NoneОдного SameSite=None недостаточно. Браузеры отклоняют cookie с None без Secure, а Secure требует HTTPS. Лаборатория отдаёт обычный HTTP на 127.0.0.1, и сохранит ли браузер cookie с Secure с этого origin, зависит от браузера. Откройте cookie в инструментах разработчика и посмотрите, существует ли она. Если она не сохранилась, в журнале сервера будет cookie_arrived=False, но причиной окажется правило Secure, SameSite тут ни при чём. Если сохранилась, отправьте форму ещё раз. Строка cookie_arrived=True с None означает, что браузер отправил cookie на межсайтовом POST. Из-за такого поведения None рискован для cookie сессии, поэтому используйте Lax или Strict, если настоящему межсайтовому сценарию не нужен None.
Когда закончите, остановите каждый сервер через Ctrl+C.
Лаборатория показывает, как cookie и SameSite меняют то, что получает сервер. Защиты она не добавляет. Маршрут /transfer по-прежнему принимает любой POST с cookie, так что с cookie SameSite=None поддельная форма сработала бы. Чтобы закрыть брешь, добавьте в форму синхронизирующий токен и проверяйте его в маршруте. Лаборатория останавливается здесь, чтобы сначала вы увидели действие одного атрибута.
evil-bank.example, и bank.example.evil.test содержат bank.example. Сравнивайте origin целиком.Прочитайте OWASP cheat sheet от начала до конца. Там описаны схемы токенов, проверки заголовков и случаи, которые каждая из них упускает. Прочитайте черновик RFC 6265bis, чтобы узнать точные правила SameSite, включая значение по умолчанию при отсутствии атрибута и обработку переходов верхнего уровня. Для быстрой справки по значениям атрибута удобнее страница MDN о Set-Cookie.
В своём приложении используйте защиту от CSRF, которую даёт фреймворк, и тестируйте её. Напишите один тест, который отправляет POST на маршрут, меняющий состояние, без токена и ожидает отказа. Напишите второй, который отправляет POST с токеном из другой сессии и тоже ожидает отказа. Если вы работаете с Flask, посмотрите расширение CSRFProtect из Flask-WTF. Смотрите предупреждения о cookie в инструментах разработчика браузера: Chrome сообщает о проблемах с SameSite на панели Issues.
О транспортном слое под этими cookie читайте в статье что делают HTTPS и TLS. Атрибут Secure что-то значит только на зашифрованном соединении. Следующая статья этой серии, как написать первый отчёт по безопасности, показывает, как записать такую находку для команды, которой принадлежит код.
Безопасность веба и API
Фронтенд на JavaScript перечисляет API, с которым говорит приложение. Модель ИИ читает бандлы и выносит endpoints и параметры, а вы проверяете каждый.
Безопасность веба и API
Методика для IDOR, XSS, SSRF и сломанной аутентификации: модель ИИ как читатель и второе мнение, плюс как проверить находку и избежать ложных срабатываний.
Безопасность веба и API
API описывают свою форму через OpenAPI и introspection в GraphQL. Модель ИИ читает схему и планирует тесты авторизации, которые вы проверяете.