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";

Вывод:

&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; &amp; «кавычки»
%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, и в письмо.

Типичные ошибки кандидатов

  • Экранируют при записи в базу вместо вывода — и получают в БД &amp;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, экранирование — на выходе, одно другое не заменяет».

Проверьте себя
1. На каком этапе нужно экранировать пользовательские данные для защиты от XSS?
AПри сохранении в базу данных
BПри валидации формы
CПри выводе, с учётом конкретного контекста (HTML, атрибут, URL, JavaScript)
DДостаточно один раз применить strip_tags() на входе
2. Почему password_hash() дважды для одного пароля даёт разные строки?
AИз-за ошибки в реализации bcrypt
BПотому что учитывается текущее время сервера
CПотому что каждый раз выбирается другой алгоритм
DПотому что при каждом вызове генерируется новая случайная соль, которая хранится внутри хэша
3. Чем сравнивать CSRF-токен, пришедший из формы, с токеном из сессии?
AФункцией hash_equals(), устойчивой к атакам по времени
BОператором == для гибкости
CФункцией strcmp() после md5()
DОператором === — этого достаточно и это безопасно