PHP / Безопасность
Обработка HTML-форм в PHP: валидация, CSRF и безопасный вывод
Как читать GET и POST в PHP, проверять строки, массивы и allowlist, защищать форму CSRF и показывать ошибки без XSS.
Содержание статьи
Данные формы недоверенные, даже если HTML ограничивает длину и варианты. Пользователь может изменить разметку или отправить HTTP-запрос вручную. PHP-обработчик должен проверить метод, структуру каждого значения, допустимый диапазон, CSRF-токен и права.
Форма профиля
<form action="/profile" method="post"> <input type="hidden" name="csrf_token" value="..."> <label for="display-name">Отображаемое имя</label> <input id="display-name" name="display_name" type="text" maxlength="80" required> <label for="email">Email</label> <input id="email" name="email" type="email" maxlength="254" required> <fieldset> <legend>Темы рассылки</legend> <label><input type="checkbox" name="topics[]" value="html"> HTML</label> <label><input type="checkbox" name="topics[]" value="css"> CSS</label> <label><input type="checkbox" name="topics[]" value="php"> PHP</label> </fieldset> <button type="submit">Сохранить</button></form>Квадратные скобки в topics[] создают массив в $_POST. Если ни один флажок не выбран, ключ может отсутствовать.
Проверка метода
<?phpdeclare(strict_types=1);if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Allow: POST'); http_response_code(405); exit('Метод не поддерживается.');}Не определяйте отправку только через isset($_POST['submit']): кнопка может не передаться, а имя кнопки не является HTTP-контрактом.
Получение строк
Значение с ожидаемым ключом может оказаться массивом. Сначала проверьте тип:
$displayName = is_string($_POST['display_name'] ?? null) ? trim($_POST['display_name']) : '';$email = is_string($_POST['email'] ?? null) ? trim($_POST['email']) : '';Вызов trim($_POST['display_name']) без проверки способен вызвать TypeError. Не используйте (string) для массива: преобразование не делает ввод корректным.
Валидация
$errors = [];if ($displayName === '') { $errors['display_name'] = 'Введите отображаемое имя.';} elseif (mb_strlen($displayName) > 80) { $errors['display_name'] = 'Имя должно быть не длиннее 80 символов.';}if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { $errors['email'] = 'Введите корректный email.';}filter_var() проверяет формат email, но не существование ящика и не владение им. Для подписки используйте double opt-in.
Не удаляйте из строки все «неподходящие» символы, превращая один ввод в другой. Для большинства полей лучше отклонить значение и объяснить правило.
Массив и allowlist
$allowedTopics = ['html', 'css', 'php'];$topicsInput = $_POST['topics'] ?? [];$topics = [];if (!is_array($topicsInput)) { $errors['topics'] = 'Некорректный формат списка тем.';} else { foreach ($topicsInput as $topic) { if (!is_string($topic) || !in_array($topic, $allowedTopics, true)) { $errors['topics'] = 'Выбрана неизвестная тема.'; break; } $topics[] = $topic; } $topics = array_values(array_unique($topics));}Сервер знает допустимые варианты и не доверяет значениям checkbox. Строгое сравнение в in_array(..., true) не включает неявное преобразование типов.
Числа
$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, [ 'options' => [ 'min_range' => 14, 'max_range' => 120, ],]);if (!is_int($age)) { $errors['age'] = 'Возраст должен быть целым числом от 14 до 120.';}Не проверяйте число только через is_numeric(): эта функция допускает форматы, которые могут не соответствовать бизнес-правилу. После разбора всегда проверяйте диапазон.
CSRF-защита
До вывода формы создайте сессию и токен:
session_start();if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32));}В форме:
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">В обработчике до изменения данных:
$token = is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : '';if (!hash_equals($_SESSION['csrf_token'], $token)) { http_response_code(403); exit('Не удалось проверить запрос. Обновите страницу.');}CSRF-токен не заменяет проверку авторизации. Сервер должен отдельно убедиться, что текущий пользователь вправе менять именно этот объект.
Безопасный вывод значений
<input id="display-name" name="display_name" type="text" value="<?= htmlspecialchars($displayName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">Для текстового контекста HTML используйте htmlspecialchars(). Не храните данные «навсегда экранированными»: для SQL, JSON, URL и JavaScript существуют другие контексты.
Ошибка также выводится безопасно:
<?php if (isset($errors['display_name'])): ?> <p id="display-name-error"> <?= htmlspecialchars($errors['display_name'], ENT_QUOTES, 'UTF-8') ?> </p><?php endif; ?>Свяжите поле с ошибкой через aria-describedby и добавьте aria-invalid="true".
Сохранение
if ($errors === []) { $statement = $database->prepare( 'UPDATE users SET display_name = :display_name, email = :email WHERE id = :id' ); $statement->execute([ 'display_name' => $displayName, 'email' => $email, 'id' => $currentUserId, ]); $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); header('Location: /profile?saved=1', true, 303); exit;}ID берётся из проверенной серверной сессии, а не из скрытого поля. SQL использует параметры. Redirect после успеха предотвращает повтор POST при обновлении.
GET-параметры
GET используют для поиска и фильтрации, которые не изменяют данные:
$query = is_string($_GET['q'] ?? null) ? trim($_GET['q']) : '';if (mb_strlen($query) > 100) { http_response_code(422); exit('Поисковый запрос слишком длинный.');}Параметр из URL так же недоверен, как POST.
Частые ошибки
- клиентские
requiredиmaxlengthсчитают защитой сервера; $_POSTчитают без проверки типа;- неизвестные варианты select и checkbox принимают без allowlist;
- POST изменяет данные без CSRF и проверки прав;
- значения вставляют в SQL строковой конкатенацией;
- пользовательский текст выводят без контекстного экранирования;
- подробное исключение базы показывают в браузере;
- после успеха возвращают HTML напрямую и получают повторную отправку.
Что запомнить
Внешний ввод проходит последовательность: проверка структуры, нормализация, валидация, авторизация, безопасная запись и экранирование при выводе. HTML улучшает интерфейс, но PHP остаётся границей доверия.