Обработка формы и защита от инъекций в PHP на живом примере
Форма обратной связи, которая пишет заявки в базу и шлёт письмо, — типичная точка входа для SQL-инъекций и подделки заголовков. Разбираем, где именно ломается наивный код и как закрыть каждую дыру.

Форма из трёх полей — имя, email, сообщение — которая складывает заявку в MySQL и отправляет письмо администратору. Кода на десять минут. Именно такие формы годами живут на сайтах и остаются главным вектором SQL-инъекций, XSS и подделки заголовков письма. Разберём наивную версию, посмотрим, где она протекает, и перепишем каждую часть так, чтобы её нельзя было сломать через curl.
Статья про обработку данных формы: валидацию, подготовленные запросы и экранирование вывода. Настройку HTTPS, CSRF-токены и капчу обозначу, но разворачивать не буду — это отдельные темы. Код проверялся на PHP 8.3 с расширением PDO и MySQL 8.
Наивная версия, которую нельзя выкатывать
Так пишут в туториалах десятилетней давности, и так до сих пор копируют:
<?php
// Как делать НЕ надо
$name = $_POST['name'];
$email = $_POST['email'];
$message = $_POST['message'];
$link = mysqli_connect('localhost', 'user', 'pass', 'shop');
$sql = "INSERT INTO requests (name, email, message)
VALUES ('$name', '$email', '$message')";
mysqli_query($link, $sql);
echo "Спасибо, $name, заявка принята";
Три проблемы в семи строках:
- SQL-инъекция. Значение
$nameпопадает в запрос как есть. Отправьте в поле', (SELECT password FROM users LIMIT 1), '')--— и структура запроса меняется. - XSS.
echo "$name"возвращает имя в HTML без экранирования. Поле со значением<script>...</script>выполнится в браузере отправителя, а если имя потом покажут в админке — и там. - Нет валидации. В базу уедет пустое сообщение, битый email и текст на мегабайт.
Как это проверяют злоумышленники
Форму никто не заполняет руками. По ней проходят автоматом:
curl -X POST https://example.com/form.php \
-d "name=test' OR '1'='1" \
-d "email=x@x.ru" \
-d "message=probe"
Если в ответ прилетает ошибка MySQL с текстом запроса — сайт уже сообщил, что уязвим. Дальше подбор идёт по стандартным сценариям.
Слой 1: валидация входных данных
Валидация — это не про безопасность запроса к базе, а про то, чтобы в систему не попали данные, которые вы не ждёте. Проверяйте на сервере всегда: проверка в браузере через required обходится тем же curl за секунду.
<?php
declare(strict_types=1);
$errors = [];
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');
if ($name === '' || mb_strlen($name) > 100) {
$errors['name'] = 'Имя обязательно, до 100 символов';
}
// filter_var с FILTER_VALIDATE_EMAIL проверяет синтаксис адреса
$email = filter_var($email, FILTER_VALIDATE_EMAIL);
if ($email === false) {
$errors['email'] = 'Некорректный email';
}
if ($message === '' || mb_strlen($message) > 5000) {
$errors['message'] = 'Сообщение обязательно, до 5000 символов';
}
if ($errors) {
http_response_code(422);
// здесь вернуть форму с текстом ошибок
exit;
}
Оператор ?? '' убирает предупреждение, если поле вообще не пришло. FILTER_VALIDATE_EMAIL проверяет только синтаксис, а не существование ящика — доставку письма это не гарантирует, но отсекает откровенный мусор.
Слой 2: подготовленные запросы против SQL-инъекций
Единственный надёжный способ защиты от SQL-инъекции — не склеивать значения со строкой запроса. В подготовленном запросе структура (текст SQL) и данные (значения) идут в СУБД раздельно, поэтому значение физически не может стать частью команды.
<?php
// PDO с включёнными исключениями
$dsn = 'mysql:host=localhost;dbname=shop;charset=utf8mb4';
$pdo = new PDO($dsn, 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // настоящие prepared, не эмуляция
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$stmt = $pdo->prepare(
'INSERT INTO requests (name, email, message, created_at)
VALUES (:name, :email, :message, NOW())'
);
$stmt->execute([
':name' => $name,
':email' => $email,
':message' => $message,
]);
Подготовленный запрос закрывает инъекцию не потому, что «экранирует кавычки», а потому, что данные вообще не участвуют в разборе SQL. Экранирование — это латание дыры, prepared statement — её отсутствие.
Важная деталь: PDO::ATTR_EMULATE_PREPARES => false. По умолчанию PDO для MySQL эмулирует подготовку на стороне драйвера, подставляя экранированные значения. В большинстве случаев это безопасно, но настоящая подготовка на стороне сервера надёжнее и не зависит от корректности экранирования в драйвере.
А если поле подставляется в имя таблицы или столбца
Плейсхолдеры работают только для значений. Имя таблицы, столбца или направление сортировки через :param подставить нельзя. Если такое поле приходит от пользователя (например, сортировка списка), сверяйте его с белым списком:
<?php
$allowedSort = ['created_at', 'name', 'email'];
$sort = in_array($_GET['sort'] ?? '', $allowedSort, true)
? $_GET['sort']
: 'created_at';
$sql = "SELECT * FROM requests ORDER BY $sort DESC";
Здесь $sort может принять только одно из трёх заранее заданных значений, поэтому подстановка в строку безопасна.
Слой 3: экранирование при выводе против XSS
Данные из базы или из $_POST нельзя печатать в HTML напрямую. Экранируйте в точке вывода — не при записи в базу. Так одни и те же данные корректно уйдут и в HTML, и в JSON, и в лог.
<?php
function e(string $value): string {
return htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
?>
<p>Спасибо, <?= e($name) ?>, заявка принята</p>
Флаг ENT_QUOTES экранирует и двойные, и одинарные кавычки — без него значение внутри атрибута value='...' останется уязвимым. Третий аргумент — кодировка, для UTF-8 указывать обязательно.
Слой 4: письмо без подделки заголовков
Отправка через mail() с пользовательским email в заголовке From — классическая дыра для header injection: перевод строки в поле позволяет дописать Bcc и рассылать спам с вашего сервера. FILTER_VALIDATE_EMAIL из слоя 1 уже отсекает адреса с переводами строк, но не полагайтесь на встроенный mail() для реальной рассылки.
Берите библиотеку, которая сама разбирает заголовки и данные корректно, — например PHPMailer или Symfony Mailer. Пользовательский email кладите в Reply-To, а в From ставьте свой домен, иначе письмо не пройдёт проверки SPF и DKIM и уедет в спам.
Собранный обработчик по порядку
- Прочитать поля через
?? ''иtrim(). - Проверить длину и формат, собрать ошибки. Есть ошибки — вернуть 422 и форму.
- Записать через подготовленный запрос PDO с
EMULATE_PREPARES = false. - Отправить письмо через библиотеку, пользовательский адрес — в
Reply-To. - Показать ответ, экранируя каждое пользовательское значение через
htmlspecialchars.
Где что применять — сводка
| Угроза | Инструмент | Точка применения |
|---|---|---|
| SQL-инъекция | PDO prepared statements, плейсхолдеры | Запрос к базе |
| SQL через имя столбца | Белый список значений | До сборки запроса |
| XSS | htmlspecialchars с ENT_QUOTES | Вывод в HTML |
| Мусор и переполнение | Проверка длины и формата | Сразу после чтения $_POST |
| Header injection в письме | Валидация email, почтовая библиотека | Перед отправкой |
Что настроить дальше
Обработка полей закрыта, но форма остаётся открытой ещё по трём направлениям, каждое — отдельная тема:
- CSRF-токен. Без него сторонний сайт может отправить форму от имени залогиненного пользователя. Генерируйте токен в сессии и проверяйте при приёме.
- Rate limiting и капча. Ограничьте число заявок с одного IP и добавьте проверку от ботов, иначе форму зафлудят.
- HTTPS. Без него данные формы уходят открытым текстом. Настраивается на уровне веб-сервера, к коду отношения не имеет.
После внедрения проверьте себя тем же способом, что и атакующий: отправьте через curl поле со значением ' OR '1'='1 и поле с <script>. В базе должна оказаться строка ровно с этим текстом, а в ответе — экранированные символы, а не выполненный скрипт.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: PHP Manual: PDO Prepared Statements, PHP Manual: htmlspecialchars
Частые вопросы
У меня старый код на mysqli, а не PDO. Переписывать всё на PDO обязательно?
Достаточно ли одной функции htmlspecialchars, чтобы закрыть XSS полностью?
А если у меня PHP 7.4, приведённый код заработает?
Я всё сделал по инструкции, но в базу всё равно попадает HTML-тег в поле имени. Это дыра?
Нужно ли всё равно экранировать значение перед записью, если я использую подготовленные запросы?
Форму атакуют ботами через curl, валидация всё отсекает, но нагрузка на сервер растёт. Что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.