Безопасность PHP-приложений
Безопасность веб-приложений на PHP — SQL-инъекции и подготовленные выражения, XSS и экранирование вывода, CSRF-токены, хеширование паролей, безопасность сессий и их фиксация, валидация входных данных против кодирования вывода, хранение секретов и ограничение частоты запросов.
12 вопросов
JuniorТеорияОчень частоПочему пароли хранят через password_hash(), а не через MD5 или SHA-1 с солью?
Почему пароли хранят через password_hash(), а не через MD5 или SHA-1 с солью?
MD5 и SHA-1 сделаны быстрыми, поэтому атакующий перебирает миллиарды вариантов в секунду — соль лишь ломает заранее посчитанные rainbow-таблицы, но перебор она не замедляет. password_hash() использует bcrypt или argon2: случайная соль на каждый хеш плюс настраиваемый cost factor делают каждую попытку намеренно дорогой. password_verify() вычитывает алгоритм, соль и cost обратно из сохранённой строки.
Типичные ошибки
- ✗Думать, что одна соль чинит MD5 — она ломает rainbow-таблицы, а не быстрый перебор
- ✗Хранить соль или cost в отдельном столбце, когда строка хеша уже несёт их в себе
- ✗Сравнивать два хеша через
===вместо вызоваpassword_verify()
Уточняющие вопросы
- →Что позволяет сделать
password_needs_rehash(), когда вы поднимаете cost factor? - →Почему вход несуществующего пользователя должен занимать столько же, сколько неверный пароль?
JuniorТеорияОчень частоЧто такое SQL-инъекция, и что именно делает подготовленное выражение неуязвимым к ней?
Что такое SQL-инъекция, и что именно делает подготовленное выражение неуязвимым к ней?
Инъекция возникает, когда пользовательский ввод склеивается со строкой SQL и становится синтаксисом, который исполняет парсер. Подготовленное выражение сначала отправляет запрос на сервер — он разбирается и планируется до того, как придёт хоть одно значение, — а связанные параметры едут отдельно, как данные. Поэтому параметр никогда не может стать SQL.
Типичные ошибки
- ✗Говорить, что подготовленное выражение экранирует ввод, а не разбирает запрос до связывания
- ✗Считать
addslashes()или ручное квотирование равноценной защитой - ✗Думать, что опасны только строковые параметры, а целые связывать не нужно
Уточняющие вопросы
- →Что меняет
PDO::ATTR_EMULATE_PREPARESв том, где запрос разбирается на самом деле? - →Какие части запроса нельзя связать параметром ни при каких условиях?
JuniorТеорияЧастоЧто такое CSRF, и почему токен в форме побеждает его там, где проверка куки бессильна?
Что такое CSRF, и почему токен в форме побеждает его там, где проверка куки бессильна?
CSRF работает потому, что браузер сам прикладывает вашу сессионную куку к любому запросу на ваш origin, включая форму, отправленную со страницы атакующего, — и кука ничего не доказывает о том, откуда пришёл запрос. Токен, который сервер вложил в вашу страницу, нельзя прочитать со стороннего сайта, поэтому отправить его может только ваша страница. SameSite сужает атаку, но это эшелонированная защита, а не замена.
Типичные ошибки
- ✗Думать, что CSRF — это кража или угадывание идентификатора сессии атакующим
- ✗Считать, что кука с
SameSiteделает токен ненужным - ✗Полагаться на заголовок
Referer, который может отсутствовать или срезаться прокси
Уточняющие вопросы
- →Почему эндпоинту на
GET, который меняет состояние, тоже нужен токен? - →Что
SameSite=Laxвсё же пропускает из того, что поймал бы токен?
JuniorТеорияЧастоПочему учётные данные базы и API-ключи не должны попадать в систему контроля версий?
Почему учётные данные базы и API-ключи не должны попадать в систему контроля версий?
Закоммиченный секрет остаётся в истории репозитория навсегда — он есть в каждом клоне, форке и логе CI, и удаление строки следующим коммитом его оттуда не убирает. Держите секреты в переменных окружения или в менеджере секретов, коммитьте только .env.example с пустыми значениями, а настоящий .env вносите в gitignore. Утёкший ключ надо ротировать, а не просто удалить.
Типичные ошибки
- ✗Считать, что приватный репозиторий делает закоммиченный секрет безопасным
- ✗Удалить строку следующим коммитом и не ротировать ключ — история его сохраняет
- ✗Коммитить настоящий
.envвместо.env.exampleс пустыми значениями
Уточняющие вопросы
- →Что вы сделаете первым делом, когда API-ключ обнаружился в публичном коммите?
- →Как доставить секрет воркеру
PHP-FPM, не записывая его на диск?
JuniorТеорияЧастоЧто после session_start() лежит в куке, а что остаётся на сервере?
Что после session_start() лежит в куке, а что остаётся на сервере?
В куке лежит только идентификатор сессии — непрозрачная случайная строка. Данные из $_SESSION живут на сервере, по умолчанию в файле внутри session.save_path, и ключом служит этот идентификатор. Поэтому идентификатор — предъявительский токен: кто им владеет, тот и есть пользователь, и потому он обязан быть непредсказуемым, ходить по HTTPS и иметь флаг HttpOnly.
Типичные ошибки
- ✗Думать, что сами данные
$_SESSIONедут в куке - ✗Полагать, что PHP привязывает сессию к адресу клиента или его user agent
- ✗Забывать, что идентификатор — предъявительский токен: кто им владеет, тот и пользователь
Уточняющие вопросы
- →Что ломается, когда два веб-сервера не делят общий
session.save_path? - →Почему
HttpOnlyна сессионной куке так важен при наличии XSS-уязвимости?
JuniorТеорияЧастоЧто такое XSS, и какой контекст вывода на самом деле закрывает htmlspecialchars()?
Что такое XSS, и какой контекст вывода на самом деле закрывает htmlspecialchars()?
XSS — это подконтрольные атакующему данные, отрисованные как разметка или скрипт в браузере другого пользователя. htmlspecialchars() кодирует <, >, & и кавычки, что делает значение безопасным как текст в теле HTML и, с ENT_QUOTES, внутри закавыченного атрибута. Но оно не станет безопасным внутри блока <script>, URL вида javascript: или незакавыченного атрибута — экранирование зависит от контекста.
Типичные ошибки
- ✗Считать один вызов
htmlspecialchars()безопасным в любом контексте вывода - ✗Экранировать на входе и хранить закодированное значение вместо экранирования на выводе
- ✗Думать, что
htmlspecialchars()вырезает теги — он кодирует символы и ничего не удаляет
Уточняющие вопросы
- →Как безопасно поместить значение из PHP внутрь блока
<script>? - →Чего автоэкранирование шаблонизатора всё равно не знает о вашем контексте вывода?
MiddleТеорияЧастоИ валидация ввода, и кодирование вывода трогают пользовательские данные. Что из них останавливает XSS и почему?
И валидация ввода, и кодирование вывода трогают пользовательские данные. Что из них останавливает XSS и почему?
Валидация решает, приемлемы ли данные для вашей предметной области — email выглядит как email, возраст лежит в 0-150, — и выполняется один раз, на входе. Кодирование решает, как значение безопасно отрисуется в одном конкретном контексте, и выполняется на каждом выводе. XSS останавливает только кодирование: одна и та же сохранённая строка безопасна как текст HTML и опасна внутри блока <script>. Валидация сужает ввод, но не делает его пригодным для печати.
Типичные ошибки
- ✗Считать валидацию границей защиты от XSS — она сужает ввод, но не делает его печатаемым
- ✗Кодировать на входе и хранить закодированное значение, что портит любой другой контекст
- ✗Полагать, что одна функция экранирования покрывает контексты HTML, JavaScript и URL разом
Уточняющие вопросы
- →Где валидация действительно останавливает атаку, если не на XSS?
- →Почему хранение HTML-кодированного текста портит ответ JSON-API, собранный из того же столбца?
JuniorТеорияИногдаЧто считает ограничитель частоты запросов к API, и по какому ключу живёт его счётчик?
Что считает ограничитель частоты запросов к API, и по какому ключу живёт его счётчик?
Он считает запросы на одну личность за одно временное окно. Счётчик живёт по ключу из того, что идентифицирует вызывающего — API-ключ, идентификатор пользователя или IP-адрес, если ничего лучше нет, — вместе с окном. Хранится он в общем хранилище вроде Redis, а не в памяти PHP, ведь каждый воркер PHP-FPM обслуживает свой запрос. За лимитом вы отвечаете 429 с заголовком Retry-After.
Типичные ошибки
- ✗Держать счётчик в памяти воркера — тогда каждый воркер отдельно выдаёт полный лимит
- ✗Ключевать только по IP, когда вызывающие сидят за одним NAT или уже предъявляют API-ключ
- ✗Не возвращать
429сRetry-After, оставляя клиента гадать, когда можно повторить
Уточняющие вопросы
- →Чем скользящее окно отличается от фиксированного ровно на границе окна?
- →Как ограничивать честно, когда множество вызывающих сидят за одним IP-адресом?
MiddleТеорияИногдаЧто такое фиксация сессии, и какой единственный вызов при входе побеждает её?
Что такое фиксация сессии, и какой единственный вызов при входе побеждает её?
Атакующий подсовывает идентификатор сессии, который уже знает — через ссылку, поддомен или подставленную куку, — и ждёт, пока жертва войдёт под ним. Этот идентификатор становится аутентифицированным, а держит его атакующий. Лечится это выдачей нового идентификатора в момент смены привилегий: session_regenerate_id(true) сразу после успешного password_verify(), что заодно уничтожает старые данные сессии.
Типичные ошибки
- ✗Путать фиксацию, где идентификатор подсовывает атакующий, с угоном, где он его крадёт
- ✗Перевыпускать идентификатор при выходе, но не при входе, где и происходит смена привилегий
- ✗Считать, что
HttpOnlyи HTTPS сами по себе остановят подсунутый атакующим идентификатор
Уточняющие вопросы
- →Зачем
session_regenerate_id(true)принимаетtrue, и что остаётся позади без него? - →Где ещё, кроме входа, смена привилегий требует нового идентификатора сессии?
MiddleТеорияИногдаВыбранный пользователем столбец сортировки попадает в ORDER BY. Почему его нельзя связать, и что делать?
Выбранный пользователем столбец сортировки попадает в ORDER BY. Почему его нельзя связать, и что делать?
Плейсхолдер — это слот для значения: сервер сначала разбирает запрос, поэтому связанный параметр может быть только данными и никогда — идентификатором или ключевым словом. ORDER BY :col отсортировал бы по константной строке, а не по столбцу. Пропустите ввод через allowlist — массив разрешённых имён столбцов — и вставляйте только то значение, которое из него достали. Направление ASC/DESC тоже идёт через allowlist.
Типичные ошибки
- ✗Пытаться связать имя столбца и получать сортировку по константной строке
- ✗Экранировать идентификатор вместо того, чтобы выбрать его из allowlist
- ✗Ограничить allowlist столбец, но
ASC/DESCвставлять прямо из запроса
Уточняющие вопросы
- →Что узнает атакующий, отсортировав по столбцу, который вы не собирались показывать?
- →Как держать allowlist в согласии с теми столбцами, которые API действительно отдаёт?
SeniorДизайнРедкоВы отдаёте публичный JSON-API из PHP-FPM за балансировщиком — несколько серверов, десятки воркеров на каждом, общей памяти у приложения нет. Нужен ограничитель частоты: анонимных вызывающих — по адресу, аутентифицированных — по аккаунту, а эндпоинт входа — заметно жёстче остальных, ведь именно он цель перебора украденных паролей. Часть законных клиентов сидит за одним корпоративным NAT-адресом, а одна партнёрская интеграция в полночь выдаёт залпом сотню вызовов и потом молчит весь день. Спроектируйте ограничитель. Разберите, где живёт счётчик, чтобы каждый воркер на каждом сервере видел одно и то же число, по какому ключу он считается и как этот ключ меняется после аутентификации вызывающего, как выбрать между фиксированным и скользящим окном с учётом полуночного залпа, что получает вызывающий за лимитом и как он узнаёт, когда повторить, и что произойдёт со всем API, если хранилище счётчиков упадёт.
Вы отдаёте публичный JSON-API из PHP-FPM за балансировщиком — несколько серверов, десятки воркеров на каждом, общей памяти у приложения нет. Нужен ограничитель частоты: анонимных вызывающих — по адресу, аутентифицированных — по аккаунту, а эндпоинт входа — заметно жёстче остальных, ведь именно он цель перебора украденных паролей. Часть законных клиентов сидит за одним корпоративным NAT-адресом, а одна партнёрская интеграция в полночь выдаёт залпом сотню вызовов и потом молчит весь день. Спроектируйте ограничитель. Разберите, где живёт счётчик, чтобы каждый воркер на каждом сервере видел одно и то же число, по какому ключу он считается и как этот ключ меняется после аутентификации вызывающего, как выбрать между фиксированным и скользящим окном с учётом полуночного залпа, что получает вызывающий за лимитом и как он узнаёт, когда повторить, и что произойдёт со всем API, если хранилище счётчиков упадёт.
Счётчик — в общем хранилище: Redis с атомарным INCR и сроком жизни, ведь каждый воркер — отдельный процесс. Ключуйте по личности, а не по соединению: идентификатор аккаунта после аутентификации, API-ключ для партнёров, адрес — только как анонимный запасной вариант, чтобы офис за одним NAT не стал единой корзиной. Вход ограничивайте отдельно и заметно жёстче. Скользящее окно не даст залпу удвоиться на границе фиксированного. Отвечайте 429 с Retry-After и осознанно выберите поведение при падении хранилища: fail-open или fail-closed.
Типичные ошибки
- ✗Считать в памяти воркера — тогда реальный лимит равен лимиту, умноженному на число воркеров
- ✗Ключевать всё по адресу, из-за чего офис за NAT схлопывается в одну корзину
- ✗Так и не решить, что делает API, когда само хранилище счётчиков недоступно
Уточняющие вопросы
- →Почему фиксированное окно позволяет отправить двойной лимит вокруг границы?
- →Вы упадёте в fail-open или fail-closed при недоступном Redis, и чего стоит этот выбор?
SeniorДизайнРедкоКоллега открывает pull request, который ради экрана отчётов выходит из ORM. Новый код руками собирает одну длинную строку SQL: SELECT, чей WHERE складывается из необязательных фильтров, отмеченных пользователем в интерфейсе, список IN (...), собранный склейкой массива идентификаторов, столбец и направление сортировки, взятые прямо из query string, и LIMIT, вставленный из параметра размера страницы. Заодно он выставил PDO::ATTR_EMULATE_PREPARES в true в фабрике соединений, потому что без этого один запрос падал. Его довод: сгенерированный ORM запрос был слишком медленным, а всё подставляемое он и так экранирует через PDO::quote(). Вы — ревьюер. Скажите, что потребуете до вливания: какие части запроса можно связать, а какие нельзя, что делать с каждой из тех, что нельзя, что меняет режим эмуляции в том, где запрос разбирается на самом деле, и что вы попросите доказать про само утверждение о производительности.
Коллега открывает pull request, который ради экрана отчётов выходит из ORM. Новый код руками собирает одну длинную строку SQL: SELECT, чей WHERE складывается из необязательных фильтров, отмеченных пользователем в интерфейсе, список IN (...), собранный склейкой массива идентификаторов, столбец и направление сортировки, взятые прямо из query string, и LIMIT, вставленный из параметра размера страницы. Заодно он выставил PDO::ATTR_EMULATE_PREPARES в true в фабрике соединений, потому что без этого один запрос падал. Его довод: сгенерированный ORM запрос был слишком медленным, а всё подставляемое он и так экранирует через PDO::quote(). Вы — ревьюер. Скажите, что потребуете до вливания: какие части запроса можно связать, а какие нельзя, что делать с каждой из тех, что нельзя, что меняет режим эмуляции в том, где запрос разбирается на самом деле, и что вы попросите доказать про само утверждение о производительности.
Значения связываются: фильтры, список IN (по сгенерированному плейсхолдеру на элемент) и LIMIT. Идентификаторы — нет: столбец сортировки и её направление обязаны приходить из allowlist, а не из запроса. PDO::quote() связывание не заменяет. А ATTR_EMULATE_PREPARES = true означает, что PDO подставляет значения на стороне клиента, и никакого серверного разбора до связывания нет вовсе — выключайте. И пусть покажут EXPLAIN и замер: «ORM был медленным» — это утверждение, а не факт.
Типичные ошибки
- ✗Принимать
PDO::quote()за равноценную замену связыванию параметра - ✗Оставить
ATTR_EMULATE_PREPARESвключённым и всё же считать, что сервер разбирает запрос первым - ✗Пытаться связать столбец сортировки или её направление вместо allowlist
Уточняющие вопросы
- →Как собрать список
IN (...)из массива переменной длины, не вставляя его интерполяцией? - →Что в
EXPLAINдействительно оправдало бы уход из ORM ради этого одного отчёта?