Безопасность форм в PHP: разбор частых ошибок
Форма обратной связи и корзина интернет-магазина — первые цели для инъекций, XSS и CSRF. Разбираем, где типичный PHP-код на приёме данных дырявый и как закрыть эти дыры без магии.

Форма с полем «сообщение» и кнопкой «отправить» кажется безобидной. Но именно через неё в базу попадает <script>, который потом выполняется в админке, а через параметр сортировки товаров — ' OR 1=1--, который выгружает всю таблицу пользователей. Ниже — конкретные ошибки, которые повторяются из проекта в проект на PHP 8.x, и как их закрыть. Разбор для разработчиков и вебмастеров, которые пишут обработчики форм руками, а не полагаются на фреймворк целиком.
Ошибка 1. SQL-инъекция: данные склеены с запросом
Классика, которая не умирает. Значение из $_POST подставляется прямо в текст запроса:
// ОПАСНО — так писать нельзя
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
$result = $mysqli->query($sql);
Если в поле прислать ' OR '1'='1, условие станет истинным для всех строк. Экранирование строк вручную через addslashes или mysqli_real_escape_string закрывает часть случаев, но ломается на числовых полях и кодировках. Единственный надёжный способ — подготовленные выражения с параметрами.
Как правильно: PDO с плейсхолдерами
// PHP 8.x, PDO
$pdo = new PDO(
'mysql:host=localhost;dbname=shop;charset=utf8mb4',
'app_user',
'YOUR_DB_PASSWORD',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email']]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
Драйвер отправляет запрос и данные раздельно, поэтому содержимое поля не может изменить структуру запроса. Важная деталь: по умолчанию PDO эмулирует подготовку на стороне PHP. Для реальной серверной подготовки задайте PDO::ATTR_EMULATE_PREPARES => false — это точное имя атрибута, проверьте его в документации PDO для вашей версии драйвера.
Правило простое: любое значение, пришедшее от пользователя, попадает в запрос только через плейсхолдер. Ни одного исключения «тут же просто число».
Ошибка 2. XSS: вывод без экранирования
Данные сохранили безопасно, а при выводе забыли обработать. Имя пользователя <script>fetch('/steal?c='+document.cookie)</script> выполнится в браузере каждого, кто откроет страницу с этим именем.
// ОПАСНО
echo "<div>Привет, {$_GET['name']}</div>";
// Правильно — экранируем при выводе в HTML
$name = htmlspecialchars($_GET['name'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
echo "<div>Привет, {$name}</div>";
Флаг ENT_QUOTES обязателен: без него одинарные кавычки не экранируются, и вывод внутри атрибута value='...' остаётся уязвимым. Экранировать нужно в точке вывода, а не при сохранении: одни и те же данные могут пойти в HTML, в JSON и в атрибут, а правила экранирования там разные.
- Вывод в тело HTML —
htmlspecialchars. - Вывод в JavaScript-контекст —
json_encodeс флагами, а не ручная вставка в кавычки. - Вывод в URL-параметр —
rawurlencode.
Ошибка 3. Нет защиты от CSRF
Форма меняет данные (смена пароля, оформление заказа), но не проверяет, что запрос пришёл с вашей страницы. Тогда сторонний сайт может отправить POST от имени залогиненного пользователя. Лечится токеном, который генерируется на сервере, кладётся в форму и в сессию, а при обработке сверяется.
// PHP 8.x — генерация токена при отрисовке формы
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf_token'];
// в форме: <input type="hidden" name="csrf_token" value="...">
// Проверка при обработке POST
$sent = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $sent)) {
http_response_code(403);
exit('Проверка CSRF не пройдена');
}
Сравнивайте токены через hash_equals, а не через ==: обычное сравнение уязвимо к атакам по времени и, что хуже, приводит строки к числам в некоторых случаях. Токен random_bytes — криптостойкий, в отличие от rand или uniqid.
Ошибка 4. Слепое доверие к типам и наличию полей
Код рассчитывает, что $_POST['qty'] всегда придёт и всегда будет числом. Пользователь (или бот) присылает массив вместо строки — и htmlspecialchars падает с ошибкой, а логика ведёт себя непредсказуемо.
// Проверяем наличие и приводим тип явно
$qty = filter_input(INPUT_POST, 'qty', FILTER_VALIDATE_INT);
if ($qty === false || $qty === null || $qty < 1) {
http_response_code(422);
exit('Некорректное количество');
}
filter_input с FILTER_VALIDATE_INT вернёт false для нечислового значения и null, если поля нет вовсе — оба случая надо обработать. Для email есть FILTER_VALIDATE_EMAIL, но помните: он проверяет формат, а не существование адреса.
Ошибка 5. Загрузка файлов по расширению имени
Форма принимает аватарку и решает, что photo.jpg — картинка, потому что заканчивается на .jpg. Загружают shell.php.jpg или файл с картинкой-обёрткой и PHP-кодом внутри, а при неверной конфигурации сервера он выполняется.
- Определяйте тип по содержимому через
finfo_fileсFILEINFO_MIME_TYPE, а не по$_FILES['...']['type']— последний присылает клиент. - Генерируйте новое имя файла сами, не используйте
$_FILES['...']['name']напрямую. - Храните загрузки вне корня веб-сервера или в каталоге, где отключено выполнение PHP.
- Ограничивайте размер и на стороне PHP (
upload_max_filesize,post_max_size), и проверкой в коде.
Сводка: ошибка и как закрыть
| Ошибка | Чем грозит | Решение |
|---|---|---|
| Склейка данных с SQL | Утечка или порча БД | PDO/mysqli с плейсхолдерами |
| Вывод без экранирования | XSS, кража сессий | htmlspecialchars с ENT_QUOTES |
| Нет CSRF-токена | Действия от чужого имени | Токен в сессии + hash_equals |
| Доверие типам полей | Сбои, обход логики | filter_input, явная проверка |
| Файлы по расширению | Выполнение чужого кода | MIME по содержимому, хранение вне корня |
Когда что выбирать: mysqli или PDO
Оба поддерживают подготовленные выражения и оба безопасны при правильном использовании. PDO даёт единый интерфейс к разным СУБД и именованные плейсхолдеры (:email), что читается лучше. mysqli работает только с MySQL/MariaDB, но иногда уже вшит в существующий код проекта. Мигрировать ради миграции смысла нет; писать новое — берите PDO.
Что сделать дальше
Пройдитесь по своим обработчикам форм с этим списком в руках — начните с тех, что пишут в базу и меняют данные. Дополнительно:
- Настройте заголовок
Content-Security-Policy— он снижает урон от XSS, даже если экранирование где-то пропущено. - Включите флаги
HttpOnlyиSecureдля cookie сессии, чтобы её нельзя было прочитать из JavaScript. - Логируйте отклонённые запросы (провал CSRF, невалидные типы) — это ранний сигнал о сканировании форм ботами.
Инъекции в шаблонах и защита API-эндпоинтов — отдельные темы, здесь мы разбирали только приём данных из HTML-форм.
Инфографика для маркетплейса: Три главных преимущества товара короткими фразами, выноски в безопасной зоне превью, точный размер площадки.
Спросить за 60 ₽Источники: PHP Manual — PDO Prepared Statements, PHP Manual — filter_input
Частые вопросы
У меня старый проект на mysqli без PDO — переписывать всё?
Достаточно ли htmlspecialchars, чтобы полностью закрыть XSS?
Работает ли этот код на PHP 7?
CSRF-токен не проходит проверку, хотя форма моя. В чём дело?
Нужно ли экранировать данные перед записью в базу?
Проверка MIME через finfo не защищает от загрузки вредоносного файла на 100%?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.