XSS, CSRF, пароли и валидация ввода
Три вопроса, которые почти гарантированно прозвучат в конце технического интервью.
Валидация отвечает на вопрос «эти данные вообще допустимы?» и делается на входе. Экранирование отвечает на вопрос «как безопасно вставить их в этот контекст?» и делается на выходе. Это разные задачи, и одна не заменяет другую.
Вопрос 1: «Что такое XSS и как от него защищаться?»
Что проверяет интервьюер
Понимаете ли вы, что защита от XSS — это экранирование при выводе, а не «чистка» при сохранении. Данные могут попасть в HTML-текст, в атрибут, в URL или в JavaScript, и правила экранирования в каждом контексте свои.
<?php
$comment = '<script>alert("xss")</script> & «кавычки»';
echo htmlspecialchars($comment, ENT_QUOTES, 'UTF-8'), "\n";
echo rawurlencode('привет мир'), "\n";
echo json_encode(['msg' => '</script>'], JSON_HEX_TAG | JSON_UNESCAPED_UNICODE), "\n";
Вывод:
<script>alert("xss")</script> & «кавычки»
%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82%20%D0%BC%D0%B8%D1%80
{"msg":"\u003C\/script\u003E"}
Три строки — три контекста: HTML (htmlspecialchars с ENT_QUOTES, иначе одинарные кавычки в атрибутах останутся живыми), URL (rawurlencode) и JavaScript (json_encode с флагом JSON_HEX_TAG, чтобы строка </script> внутри данных не закрыла тег).
Что ещё стоит назвать:
- Шаблонизаторы вроде Twig экранируют вывод по умолчанию — отключать это фильтром
|rawнужно осознанно. - Если пользователю разрешён HTML (комментарии, статьи), одного
strip_tags()мало — берут HTML Purifier с белым списком тегов и атрибутов. - Заголовок
Content-Security-Policy— второй рубеж: он запрещает выполнение инлайновых скриптов, даже если экранирование где-то забыли. - Stored XSS (записан в базу) опаснее reflected (только в ответе на запрос), но лечатся они одинаково — экранированием при выводе.
Вопрос 2: «Как защититься от CSRF?»
Суть атаки: сторонний сайт отправляет запрос на ваш домен от имени залогиненного пользователя — браузер сам приложит куки. Классическая защита — синхронизирующий токен: случайное значение кладётся в сессию и в скрытое поле формы, а на приёме сравнивается.
<?php
$token = bin2hex(random_bytes(16));
echo strlen($token), "\n";
$fromForm = $token;
var_dump(hash_equals($token, $fromForm));
var_dump(hash_equals($token, 'подделанный токен'));
Вывод:
32 bool(true) bool(false)
// генерация при отрисовке формы
$_SESSION['csrf'] = $_SESSION['csrf'] ?? bin2hex(random_bytes(32));
?>
<form method="post">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) ?>">
</form>
<?php
// проверка при обработке
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(419);
exit('CSRF token mismatch');
}
Ключевые детали: токен генерируется random_bytes() (криптографически стойкий источник, а не rand()), сравнивается через hash_equals(), и защищать нужно все изменяющие состояние запросы — POST, PUT, DELETE. Дополнительный рубеж — кука с SameSite=Lax. А вот проверка Referer ненадёжна: заголовок легко отсутствует.
Вопрос 3: «Как хранить пароли?»
<?php
$hash = password_hash('koshka2026', PASSWORD_BCRYPT, ['cost' => 10]);
echo substr($hash, 0, 4), "\n";
echo strlen($hash), "\n";
var_dump(password_verify('koshka2026', $hash));
var_dump(password_verify('koshka2027', $hash));
var_dump(password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12]));
$second = password_hash('koshka2026', PASSWORD_BCRYPT, ['cost' => 10]);
var_dump($hash === $second);
Вывод:
$2y$ 60 bool(true) bool(false) bool(true) bool(false)
Разберём каждую строку — это готовый развёрнутый ответ:
$2y$— префикс алгоритма bcrypt; соль генерируется автоматически и хранится внутри строки хэша, отдельное поле для неё не нужно.- Два хэша одного и того же пароля различаются — потому и
$hash === $secondдаётfalse. Сравнивать хэши напрямую нельзя, для проверки естьpassword_verify(). password_needs_rehash()позволяет незаметно поднять стойкость: если параметры устарели, пересчитываем хэш прямо в момент успешного входа, когда пароль в открытом виде под рукой.- Стоимость (cost) — это осознанное замедление: подбор должен быть дорогим. Ориентир — около 100 мс на проверку на вашем железе.
И то, чего делать нельзя: md5() и sha1() для паролей (они быстрые — значит, перебор дёшев), собственная «соль» вместо встроенной, обрезка пароля по длине и ограничение набора символов.
Вопрос 4: «Чем валидация отличается от экранирования?»
<?php
var_dump(filter_var('user@example.com', FILTER_VALIDATE_EMAIL));
var_dump(filter_var('не-почта', FILTER_VALIDATE_EMAIL));
var_dump(filter_var('42', FILTER_VALIDATE_INT));
var_dump(filter_var('42abc', FILTER_VALIDATE_INT));
var_dump(filter_var('7', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5]]));
Вывод:
string(16) "user@example.com" bool(false) int(42) bool(false) bool(false)
Обратите внимание: filter_var возвращает приведённое значение или false. Поэтому проверять результат нужно строго — === false, иначе валидное значение 0 будет принято за ошибку. Валидация происходит один раз, на входе; экранирование — каждый раз при выводе и своё для каждого контекста. Именно поэтому «почистить данные при сохранении» — плохая стратегия: одни и те же данные могут попасть и в HTML, и в JSON, и в письмо.
Типичные ошибки кандидатов
- Экранируют при записи в базу вместо вывода — и получают в БД
&lt;вместо текста. - Используют
htmlspecialchars()безENT_QUOTESи оставляют дыру в атрибутах с одинарными кавычками. - Считают, что CSRF-токен нужен и для GET-запросов, но забывают, что GET вообще не должен менять состояние.
- Генерируют токены через
rand(),uniqid()илиmd5(time())вместоrandom_bytes(). - Хранят пароли как
md5($pass . $salt)и называют это «солью». - Сравнивают секреты через
==или===вместоhash_equals(). - Путают аутентификацию (кто ты) с авторизацией (что тебе можно) и забывают проверять права на каждом действии.
Как ответить кратко
«От XSS защищает экранирование при выводе и под конкретный контекст:
htmlspecialcharsсENT_QUOTESдля HTML,rawurlencodeдля URL,json_encodeдля JS; сверху — CSP. От CSRF — синхронизирующий токен изrandom_bytes, проверяемый черезhash_equals, плюс кукаSameSite. Пароли храню черезpassword_hash/password_verify: соль внутри хэша, стойкость настраивается, устаревшие хэши пересчитываю поpassword_needs_rehash. Валидация на входе черезfilter_var, экранирование — на выходе, одно другое не заменяет».