Истории о взломах сайтов похожи одна на другую. Внимание привлекают названия и масштаб, но разбор почти всегда упирается в одно и то же: форма без проверки ввода, устаревшая библиотека, пароль по умолчанию, секретный ключ, закоммиченный в репозиторий. Нападающий редко побеждает криптографию, он побеждает привычки. Именно поэтому разбор типичных сценариев приносит больше пользы, чем чтение свежих новостей: паттерны атак стабильны десятилетиями, и защита от каждого из них давно известна, описана и проверена.
Типичные сценарии атак повторяются потому, что повторяются ошибки
Если взять сотню разборов инцидентов и убрать названия, останется короткий список сюжетов. Ввод, который система трактует как команду, - инъекции. Вывод, который браузер трактует как код, - межсайтовые сценарии. Запрос, который страница отправляет от имени ничего не подозревающего человека, - подделка. Секрет, лежащий там, где его найдёт любой сканер, - утечка. И слабая авторизация: пароли без правил, сессии без срока.
Этот список покрывает подавляющее большинство реальных происшествий. Новые атаки почти всегда оказываются вариациями старых, у которых сменилась упаковка. Для защитника отсюда следует спокойный вывод: не нужно угадывать будущее, достаточно методично закрывать известные классы проблем. Сайт, где исправлены пять базовых сюжетов, уже недоступен для девяти из десяти случайных нападающих, которые сканируют сеть в поисках лёгкой цели.
Важная деталь современного ландшафта: сканирование автоматизировано. Речь не о человеке, который целится в конкретный сайт, а о ботах, которые методично обходят диапазоны адресов и проверяют стандартные дыры на каждом встречном сервере. Для владельца проекта это означает, что вопрос стоит не так: не заинтересует ли сайт злоумышленника, а выдержит ли сервер первый автоматический проход, который происходит постоянно и без приглашения.
Инъекции остаются главной ошибкой там, где запросы собираются вручную
Сюжет инъекции возникает, когда строка запроса собирается склейкой, и ввод посетителя оказывается внутри команды. База данных или командная оболочка не отличает данные от команды: всё, что попало в текст, выполняется. Обобщённый пример выглядит невинно и при этом заканчивается потерей всей таблицы:
// опасно: ввод становится частью команды
const q = "SELECT * FROM users WHERE email = '" + email + "'";
db.query(q);
// правильно: ввод остаётся значением и не может изменить команду
db.query("SELECT * FROM users WHERE email = $1", [email]);
Разница в один приём, а результат различается принципиально: параметризованный запрос передаёт значение как значение, и никакая кавычка из ввода не закрывает строку команды. То же правило работает для командной оболочки: аргументы передаются списком, а не строкой, и тогда пробелы во вводе не превращаются в разделители команд.
На уровне команды защита проверяется поиском по кодовой базе: конкатенация строк рядом с методами выполнения запросов - точка для ревью. В больших проектах такое место находят даже простым поиском текстов запросов, собранных из переменных. Правило звучит жёстко: если ввод попадает в команду склейкой, это дефект, который чинится до релиза, а не после первого инцидента.
Разновидностей инъекции несколько: текст запроса к базе, параметры командной строки, пути к файлам, значения, попадающие в заголовки и логи. Приём защиты общий: значение всегда передаётся отдельно от команды. Дополнением служит принцип минимальных прав: учётная запись базы, под которой работает сайт, не владеет правами администратора и не имеет доступа к служебным таблицам, и тогда даже успешная инъекция упирается в стену ограничений.
Межсайтовые сценарии крадут сессии через доверчивый вывод
Второй сюжет симметричен первому. При инъекции ввод трактуется как команда на сервере, при межсайтовом сценарии сохранённый ввод трактуется как код в браузере. Человек оставляет комментарий со скриптом, страница честно показывает комментарий всем, и скрипт выполняется в чужом браузере с чужой сессией. Дальше сценарий стандартный: куки уходят на сторону, и аккаунт перестаёт принадлежать владельцу.
Современные фреймворки экранируют вывод по умолчанию, и это спасает в большинстве случаев. Проблема начинается там, где разработчик сознательно отключает экранирование, чтобы вывести "красивый" HTML из базы. Дисциплина простая: хранится структура, а не готовый HTML, а места, где HTML действительно нужен, проходят отдельное ревью с санитизацией.
Второй слой защиты - политика безопасности содержимого, которая запрещает выполнение скриптов из непозволенных мест:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'
Такая политика ломает простейшие схемы внедрения: даже если чужой текст попал на страницу, браузер отказывается выполнять встроенные скрипты. Куки при этом помечаются HttpOnly, чтобы скрипты вообще не могли их прочитать, даже при успешном внедрении.
Межсайтовые сценарии делятся на два вида по месту хранения: сохранённый ввод исполняется у каждого посетителя страницы, отражённый - только у того, кто перешёл по подготовленной ссылке. Оба лечатся одинаково: экранированием при выводе с учётом контекста, потому что правила для текста, атрибутов и вставок в скрипты различаются, и универсального экрана не существует.
Подделка запросов бьёт по действиям авторизованных людей
Третий сюжет не требует взламывать сам сайт. Злоумышленник готовит страницу, которая незаметно отправляет запрос на чужой сервис от имени того, у кого открыта сессия. Браузер честно прикладывает куки, сервер честно видит авторизованного человека, и действие выполняется: смена почты, перевод настроек, оформление операции. Человек весь это время читал новости на другом сайте.
Защита строится из двух частей. Сначала печенье сессии получает атрибут SameSite со строгим значением, и браузер перестаёт отправлять его с чужих страниц. Затем каждое изменяющее действие получает собственный токен, который известен только родной странице:
<input type="hidden" name="csrf" value="токен-из-сессии">
Проверка на сервере сравнивает токен формы с токеном сессии, и запрос со сторонней страницы, не знающей токена, отклоняется. Дополнительное правило без исключений: изменяющие действия не выполняются запросами GET, потому что ссылка с параметрами слишком легко подсовывается ничего не подозревающему человеку.
Выбор значения SameSite подчиняется балансу удобства: строгий режим рвёт переходы по внешним ссылкам, мягкий закрывает основную массу сценариев и почти не заметен посетителю. Разумный минимум для любого проекта: мягкий режим на всех печеньях сессии и строгий на чувствительных действиях вроде подтверждения платежей и смены почты.
Пароли и сессии требуют взрослого обращения
Четвёртый сюжет - самый старый: пароли хранятся неправильно, и после любой утечки таблица превращается в список открытых дверей. Быстрые хэши вроде старых стандартов не считаются защитой, потому что современные подборщики перебирают миллиарды вариантов в секунду. Медленные функции с уникальной солью меняют картину: проверка одного пароля стоит ощутимого времени, и массовый перебор теряет смысл.
const argon2 = require("argon2");
async function register(email, password) {
const hash = await argon2.hash(password);
await db.users.insert({ email, hash });
}
async function login(email, candidate) {
const user = await db.users.findByEmail(email);
return user ? argon2.verify(user.hash, candidate) : false;
}
Вокруг паролей достраивается остальное. Повторные попытки входа замедляются и после серии неудач блокируются с уведомлением владельцу. Идентификатор сессии обновляется после каждого входа, чтобы старый не работал после кражи. Чувствительные действия подтверждаются повторно. Полный набор признаков зрелой авторизации выглядит так:
- пароль хранится только через медленный хэш с уникальной солью;
- повторные попытки входа замедляются, а серия неудач вызывает блокировку и письмо;
- идентификатор сессии меняется после каждого входа и имеет короткий срок;
- чувствительные действия требуют повторного подтверждения;
- выход и смена пароля закрывают все активные сессии на всех устройствах.
Отдельное слабое звено - восстановление пароля. Ссылка сброса живёт недолго, приходит одноразовой и не раскрывает факт существования учётной записи посторонним. Политика паролей давно сместилась от сложности к длине: фраза из четырёх несвязанных слов надёжнее набора символов, который человек записывает на стикере и клеит к монитору. Словари распространённых паролей отсеиваются при регистрации, и это дешевле любых последующих разбирательств.
Утечки через зависимости и секреты в репозитории
Пятый сюжет самый обидный: сайт не взламывают, ключи просто лежат на виду. Секреты в коде - пароли баз, ключи платёжных шлюзов, токены рассылок - попадают в историю репозитория и остаются там навсегда, даже после удаления из текущей версии. Публичные сканеры индексируют такие находки автоматически, и время от обнаружения до использования измеряется минутами.
Правило хранения простое: секреты живут в переменных окружения или отдельном защищённом хранилище, а файл с примером заполнения лежит в репозитории вместо реальных значений. Сканеры секретов встраиваются в конвейер и не пропускают коммит с подозрительной строкой.
Зависимости - второй путь утечки. Устаревшая библиотека с известной дырой открывает дверь в приложение без единой ошибки в собственном коде. Рабочая гигиена: фиксация версий lock-файлом, регулярные обновления небольшими порциями и автоматический сканер уязвимостей в конвейере сборки, который роняет сборку при обнаружении критичной проблемы. Скрытую правку при таком порядке заметно невозможно, потому что каждое изменение версии проходит через конвейер с проверками.
Если ключ всё же попал наружу, порядок действий отработан заранее: сначала отзыв и выпуск нового ключа, потом чистка истории и разбор, как ключ туда попал. Разумные команды держат запасной набор ключей именно для такого дня, чтобы восстановление не превращалось в аврал. Регулярная ротация длинных ключей снижает цену случайной утечки: скомпрометированный ключ живёт недолго и не успевает наделать дел.
Культура безопасности важнее отдельных заплаток
Все перечисленные техники стоят недорого, если они встроены в привычки команды. Ревью задает три вопроса к каждой правке: откуда приходят данные, куда они выводятся и кто может это выполнить. Тесты проверяют, что сервер отклоняет известные вредоносные входы. Логи фиксируют попытки и позволяют отличить всплеск шума от начала целенаправленной атаки. План реакции написан заранее и называет людей, действия и порядок, потому что в момент инцидента думать некогда.
Зрелость измеряется временем реакции: сколько минут проходит от сигнала до изоляции проблемы, сколько часов до закрытия дыры и отзыва ключей. Команды, которые тренировали сценарии, отвечают в разы быстрее, и это единственная метрика, которая честно показывает состояние защиты.
Польза от сообщений о найденных проблемах поощряется, а не наказывается: специалист, который принёс дыру, получает благодарность, а не выговор. Внутренние проверки полезно устраивать и на собственных проектах: попытка пройти авторизацию хитрым вводом на тестовом окружении быстрее любого аудита показывает, где привычки дали сбой.
Безопасность в итоге не состояние, а режим работы. Классы известных атак конечны, защита от каждого описана, и методичное закрытие списка даёт результат, который не обеспечит ни один разовый аудит. Сайт, где инъекции невозможны по построению, вывод экранирован, действия подтверждены токенами, пароли медленно хэшированы, а секреты лежат в хранилище, спокойно проходит девять из десяти волн сканирования сети, и нападающему проще искать следующую жертву.