Атаки и их предотвращение, которые должен знать каждый веб-разработчик
Безопасность почти всегда ломается в стыке: «валидация была», но не там; «авторизация есть», но не на одном эндпоинте; «данные экранируются», но не в одном месте в шаблоне. Хорошая новость: большая часть реальных инцидентов укладывается в повторяющиеся классы ошибок — их можно закрывать системно.
Связанные материалы: Наблюдаемость и мониторинг · БД под нагрузкой
Содержание
- Модель угроз: что защищаем и от кого
- Инъекции: SQLi, command injection, template injection
- XSS: отражённый, хранимый, DOM-based
- CSRF: почему «я же GET» не спасает
- Аутентификация и сессии: кража токенов, фиксация, куки
- Контроль доступа и IDOR: «это же просто id»
- Загрузки файлов и путь: upload, path traversal, RCE рядом
- SSRF: когда сервер ходит «куда не надо»
- Небезопасная десериализация и подмена объектов
- Браузерные защиты: clickjacking, CORS, заголовки
- DoS и abuse: rate limit, брутфорс, дорогостоящие операции
- Чеклист для ревью и релиза
Модель угроз: что защищаем и от кого
Перед техникой — короткая рамка:
- Атакующий: анонимный пользователь, пользователь с аккаунтом, партнёр с API-ключом, «свой» в VPN, злоумышленник с доступом к CI/CD.
- Активы: деньги, персональные данные, аккаунты, админка, интеграции, секреты, доступ к внутренней сети.
- Поверхность атаки: формы и API, редиректы, webhooks, импорты/экспорты, загрузки файлов, интеграции (S3, SMTP, платежи).
Практическое правило: любой ввод недоверенный (включая заголовки, cookies, webhook payload, параметры URL, JSON из очереди).
Инъекции: SQLi, command injection, template injection
SQL Injection (SQLi)
SQLi почти всегда начинается с «всего один сырой кусок SQL». Защита — не «экранировать», а параметризовать.
Плохо (конкатенация):
$rows = DB::select("SELECT * FROM users WHERE email = '{$email}'");
Лучше (плейсхолдеры):
$rows = DB::select('SELECT * FROM users WHERE email = ?', [$email]);
Ещё лучше — держать запросы в Eloquent/Query Builder там, где это возможно, и не хранить «режимы» числом (см. также заметки про PDO в гайдах по версиям PHP).
Отдельный класс проблем — динамические имена колонок / сортировка. Параметры не подставляют идентификаторы, поэтому делайте allowlist:
$allowed = ['created_at', 'email', 'id'];
$sort = in_array($request->get('sort'), $allowed, true) ? $request->get('sort') : 'created_at';
$users = User::query()->orderBy($sort, 'desc')->paginate();
Command injection
Если приложение вызывает shell, защита — не пропускать пользовательский ввод в командную строку. Даже escapeshellarg() — не серебряная пуля, а последний барьер.
Если нужно конвертировать файл/генерировать превью:
- предпочитайте библиотеку, а не
exec(); - если всё же CLI — фиксируйте бинарник, аргументы и директории, запускайте в ограниченном окружении (контейнер/очередь/worker под отдельным пользователем).
Template injection
Самый опасный вариант — когда вы позволяете пользователю задавать «шаблон», который потом исполняется как Blade/Twig/Smarty.
Правило: пользовательский шаблон — только как данные, максимум через ограниченный, не Turing-complete формат (например, Markdown с жёстко заданным набором разрешённых тегов и безопасным рендером).
XSS: отражённый, хранимый, DOM-based
XSS — это выполнение JS в контексте вашего домена. Типовые источники:
- вывод «как есть» (
{!! ... !!}в Blade,innerHTMLво фронте); - HTML в профиле/комментариях без санитайза;
- вставка данных в
<script>/атрибуты без правильного экранирования.
Базовые меры
- Экранирование по умолчанию: в Blade используйте
{{ $value }}. Не используйте raw-вывод без жёсткого основания. - Разделяйте контексты: HTML, атрибуты, JS-строки, URL — разные правила экранирования.
- Санитайз HTML**-ввода**: если вы разрешаете «форматирование», применяйте allowlist (теги/атрибуты) и вырезайте
on*-обработчики,javascript:-URL и т.п. - CSP (Content Security Policy): это не исправляет логику, но резко снижает ущерб от XSS.
Пример CSP-стратегии для старта (идеально — с nonces и без 'unsafe-inline'):
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
CSRF: почему «я же GET» не спасает
CSRF — когда браузер пользователя «сам» отправляет запрос на ваш сайт с его cookies/сессией, потому что это делает другой сайт (формой, изображением, JS).
Меры защиты
- CSRF-токены на state-changing запросах (POST/PUT/PATCH/DELETE) — Laravel делает это из коробки.
SameSitecookies: минимумLax, для чувствительных сценариев —Strict(с пониманием UX), для кросс-сайтовых кейсов —None; Secure.- Проверка Origin/Referer на особо критичных действиях (смена email, вывод средств).
- Не делать изменения состояния через GET вообще.
Аутентификация и сессии: кража токенов, фиксация, куки
Что обычно ломают:
- слабые пароли + отсутствие rate limiting → credential stuffing;
- сессии: XSS украл токен/куки; фиксация сессии; токен не инвалидируется;
- долгоживущие токены без ротации и привязки к устройству.
Минимальный набор:
- Rate limit на логин/OTP/восстановление пароля.
- Хэширование паролей только
password_hash()(bcrypt/argon2), без «своих» криптосхем. - Cookies для сессии:
HttpOnly,Secure,SameSite, короткая жизнь там, где можно. - Ротация session ID после логина и повышения прав (Laravel умеет
regenerate()).
Контроль доступа и IDOR: «это же просто id»
IDOR (Insecure Direct Object Reference) — когда пользователь угадывает/перебирает идентификаторы и видит/меняет чужие ресурсы.
Типичные причины:
- проверка «пользователь залогинен» вместо проверки владения/ролей;
- авторизация есть в UI, но нет на API;
- «админский» параметр в запросе управляет поведением.
Защита
- Политики/гейты на каждый ресурс и каждое действие (view/update/delete).
- Мульти-тенантность: всегда фильтруйте по
tenant_id/account_idна уровне запросов. - Не полагайтесь на скрытие (кнопки/роуты) как на безопасность.
- Логи доступа к чувствительным объектам (особенно чтение PII).
Пример (идея): вместо User::findOrFail($id) — auth()->user()->orders()->findOrFail($id).
Загрузки файлов и путь: upload, path traversal, RCE рядом
Загрузки — любимое место для RCE «рядом с» приложением.
Что проверять
- Содержимое, а не только расширение. MIME от браузера — недоверенный.
- Ограничение размера и количества файлов.
- Хранение вне web-root, раздача через контроллер или отдельный домен/CDN.
- Случайные имена (не использовать исходное имя как путь).
- Запрет исполнения в директории upload (на уровне веб-сервера).
Laravel-паттерн (идея): Storage::disk('private')->putFile(...) и выдача через signed URL/контроллер.
Path traversal чаще всего появляется в «скачай файл по имени»:
- запрещайте
../,\, нулевые байты; - используйте allowlist реальных файлов/идентификаторов, а не путь из запроса.
SSRF: когда сервер ходит «куда не надо»
SSRF возникает, когда вы берёте URL от пользователя и делаете запрос от имени сервера (импорт аватарки, webhook tester, «проверить ссылку», PDF-сервис).
Опасность: доступ к внутренним адресам (metadata endpoints облака, Redis/Consul, админки), обход сетевых границ.
Защита
- Allowlist доменов/хостов, а не blacklist.
- Запрет внутренних IP-диапазонов (127.0.0.0/8, 10.0.0.0/8, 169.254.0.0/16, 172.16/12, 192.168/16, ::1, fc00::/7).
- Отключение редиректов или проверка цепочек редиректов.
- Таймауты, лимиты размера ответа, ограничение протоколов (только https).
Небезопасная десериализация и подмена объектов
Если вы десериализуете данные, которые может подделать атакующий (cookies, hidden fields, payload из внешней очереди, кэш без подписи), вы рискуете:
- подменой полей/ролей;
- gadget chain и RCE (в экосистемах, где это возможно).
Правило: не хранить сериализованные объекты в недоверенных местах. Для токенов — подпись (HMAC), для сложных данных — JSON + строгая схема + серверная валидация.
Браузерные защиты: clickjacking, CORS, заголовки
Clickjacking
Закрывайте встраивание в iframe:
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
CORS
Суть: CORS — не «защита от запросов», а политика чтения ответа браузером. Ошибки:
Access-Control-Allow-Origin: *вместе с cookies/credentials;- разрешение лишних методов/заголовков;
- доверие к
Originбез строгого списка.
Базовые security headers
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
DoS и abuse: rate limit, брутфорс, дорогостоящие операции
Самые частые «полу-DoS» в бизнес-приложениях — это не гигабиты трафика, а дешёвые для атакующего, дорогие для вас запросы:
- поиск без индексов, экспорт больших отчётов, генерация PDF;
- endpoints без rate limit (логин, отправка email/OTP, «проверка промокода»);
- сложная валидация/регулярки на больших строках.
Меры:
- rate limiting по IP + по аккаунту + по ключу (в зависимости от сценария);
- очереди на дорогие операции;
- таймауты, лимиты размеров, ограничения глубины JSON;
- кэширование «публичных» тяжёлых ответов (с учётом персонализации!).
Чеклист для ревью и релиза
Ввод и данные
- [ ] Валидация входа на границе (формы/API/webhooks), нормализация типов.
- [ ] Allowlist для сортировки/фильтров/полей, которые становятся идентификаторами в запросах.
- [ ] Нет state-change через GET.
Рендер и браузер
- [ ] Экранирование по умолчанию, нет необоснованного raw HTML.
- [ ] CSP/
frame-ancestors, защита от clickjacking. - [ ] HSTS,
nosniff, разумныйReferrer-Policy.
Доступ
- [ ] Авторизация на сервере для каждого действия (policy/gate), не только «в UI».
- [ ] Нет IDOR: выборки ограничены владельцем/тенантом.
- [ ] Логи аудита для чувствительных операций.
Интеграции
- [ ] SSRF: allowlist доменов, запрет внутренних IP, лимиты и таймауты.
- [ ] Webhooks: подпись, защита от повторов (replay), идемпотентность.
Uploads
- [ ] Проверка типа/размера, хранение вне web-root, запрет исполнения.
- [ ] Случайные имена, нет путей из пользовательского ввода.
Итог
Если нужно запомнить один принцип: безопасность — это инварианты на границе. Ввод проверяется один раз и правильно; вывод экранируется по умолчанию; доступ всегда проверяется сервером; интеграции «снаружи» подписаны; всё, что дорого, ограничено по частоте и вынесено из критического пути.