Безопасность PHP-приложений
Каждая уязвимость в этой теме — это одна и та же ошибка в разных костюмах: данные, которыми управляет чужой человек, попадают туда, где их читают как инструкции. В SQL-инъекции ввод становится синтаксисом запроса. В XSS — разметкой в чужом браузере. В массовом присваивании — именем колонки. Механизм защиты во всех случаях один по форме: отделить данные от кода структурно, а не пытаться вычистить из данных опасные символы.
Отсюда следует, почему на собеседовании так важна формулировка. «Подготовленное выражение экранирует ввод» — неверный механизм, и он выдаёт кандидата мгновенно. Верный: сервер разбирает и планирует запрос до того, как получит хоть одно значение, поэтому параметр физически не может стать SQL. Ровно так же: «мы солим пароли» — неверный механизм (соль ломает rainbow-таблицы, но не замедляет перебор), а верный — хеш должен быть намеренно медленным. И так же: htmlspecialchars() закрывает контекст текста HTML — и не закрывает контекст JavaScript, URL или незакавыченного атрибута, потому что экранирование зависит от контекста. Разбор по слоям — ниже.
Карта темы
- SQL-инъекция — почему запрос разбирается до связывания параметров, что ломает
PDO::ATTR_EMULATE_PREPARESи почемуPDO::quote()не заменяет связывание. - Валидация ввода — валидация против кодирования вывода, и allowlist для того единственного, что связать нельзя: имени столбца в
ORDER BY. - XSS — какой контекст вывода на самом деле закрывает
htmlspecialchars(), и почему в<script>, в URL и в незакавыченном атрибуте он бесполезен. - CSRF — почему браузер сам прикладывает вашу куку, что доказывает токен, и почему
SameSite— эшелонированная защита, а не замена. - Безопасность сессий — идентификатор как предъявительский токен, флаги куки, фиксация сессии и
session_regenerate_id(true). - Хеширование паролей — намеренно медленный хеш,
password_hash()иpassword_verify()против быстрых MD5 и SHA-1, и почему «просто добавить соль» — неверный ответ. - Хранение секретов — почему закоммиченный секрет остаётся в истории навсегда и почему утёкший ключ ротируют, а не удаляют.
- Ограничение частоты запросов — счётчик в общем хранилище, ключ по личности, окно,
429сRetry-Afterи выбор fail-open против fail-closed.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Говорить, что подготовленное выражение «экранирует ввод» | Неверный механизм: запрос разбирается до связывания, поэтому параметр не может стать SQL |
Оставить PDO::ATTR_EMULATE_PREPARES включённым (умолчание MySQL-драйвера) | Серверного разбора до связывания нет вовсе: PDO склеивает SQL на клиенте |
Считать PDO::quote() или addslashes() равноценной защитой | Это экранирование в каждом месте вызова — забыли один раз, и инъекция открыта |
Пытаться связать имя столбца или ASC/DESC в ORDER BY | Плейсхолдер — слот для значения: отсортируете по константной строке. Нужен allowlist |
| Ограничить allowlist столбец, а направление сортировки вставить из запроса | ORDER BY title {$_GET['dir']} — та же инъекция, просто в другом месте |
| Считать валидацию защитой от XSS | Валидация сужает ввод, но не делает его пригодным для печати — XSS останавливает кодирование |
| Кодировать на входе и хранить закодированное значение | Значение испорчено для любого другого контекста: JSON, email, PDF, поиск |
Считать htmlspecialchars() универсальным | Он закрывает текст HTML и (с ENT_QUOTES) закавыченный атрибут — и не закрывает <script>, javascript: и незакавыченный атрибут |
Думать, что htmlspecialchars() вырезает теги | Он кодирует символы и ничего не удаляет |
| Считать, что CSRF — это кража идентификатора сессии | Нет: кука уходит сама, атакующий её не видит — он лишь заставляет браузер её приложить |
Считать, что SameSite делает CSRF-токен ненужным | Это эшелонированная защита, а не замена: остаются старые браузеры, поддомены и SameSite=None |
Полагаться на заголовок Referer | Может отсутствовать, быть срезан прокси или политикой — на нём нельзя строить решение |
| Думать, что соль чинит MD5 | Соль ломает rainbow-таблицы, но не замедляет перебор: суть в намеренно медленном хеше |
| Хранить соль или cost в отдельном столбце | Строка password_hash() уже несёт алгоритм, cost и соль внутри себя |
Сравнивать хеши через === | Нужен password_verify(): он вычитывает параметры из хеша и сравнивает за постоянное время |
| Перевыпускать идентификатор сессии при выходе, но не при входе | Смена привилегий происходит на входе — именно там и живёт фиксация сессии |
| Считать закоммиченный секрет безопасным, потому что репозиторий приватный | Он есть в каждом клоне, форке и логе CI; удаление строки его из истории не убирает |
| Держать счётчик рейт-лимита в памяти воркера | Каждый воркер выдаст полный лимит: реальный лимит умножается на число воркеров |
Значение для собеседований
Безопасность — тема, где кандидата отделяют по точности формулировки механизма. Все знают слова «подготовленные выражения», «соль», «токен», «экранирование». Разница в том, что за ними стоит. Кандидат, который говорит «PDO экранирует ввод», не сможет объяснить, почему нельзя связать ORDER BY — а тот, кто говорит «запрос разбирается до того, как приедут значения», выводит это следствие сам, из механизма.
Что обычно проверяют:
- Что именно делает подготовленное выражение неуязвимым — и что меняет режим эмуляции.
- Что можно связать, а что обязано идти через allowlist.
- Чем валидация отличается от кодирования и что из них останавливает XSS.
- Какой контекст закрывает
htmlspecialchars()и какие три не закрывает. - Почему CSRF вообще работает и что доказывает токен.
- Почему
password_hash(), а не «MD5 с солью», и что уже лежит внутри строки хеша. - Что такое фиксация сессии и какой единственный вызов при входе её побеждает.
- Где живёт счётчик рейт-лимита и почему не в памяти PHP.
Типичный неверный ответ: «Мы всё экранируем». Экранирование — это ответ на вопрос «как обезвредить символы», а вопрос стоит иначе: «как сделать так, чтобы данные вообще не попадали в позицию кода». Подготовленное выражение отвечает на второй вопрос — структурно. Второй классический провал — «htmlspecialchars спасает от XSS»: спасает в одном контексте, а строка, безопасная как текст HTML, внутри <script> остаётся исполняемым кодом.