Разбери XSS, CSRF и SQL-инъекции по косточкам, чтобы точно понять, что именно блокируют htmlspecialchars, токены и подготовленные запросы.
Открыть этот урок в KodokonУ трёх больших семейств веб-уязвимостей - XSS, CSRF и инъекций - одна и та же первопричина: данные, переданные пользователем, в итоге интерпретируются как код (HTML, SQL или законный запрос). Начнём с XSS: если ты выводишь значение из $_GET без экранирования, злоумышленник может внедрить тег <script> и выполнить JavaScript в браузерах твоих посетителей - украсть сессии, совершить действия без их ведома. При ?comment=<script>alert(1)</script> первый вывод ниже запускает скрипт, а второй его обезвреживает. Здесь важны два флага: ENT_QUOTES преобразует и одинарные кавычки (иначе атрибут, ограниченный одинарными кавычками, остаётся уязвимым для внедрения), а ENT_SUBSTITUTE заменяет некорректные последовательности UTF-8 вместо того, чтобы вернуть пустую строку. Начиная с PHP 8.1 оба флага наконец стали значениями по умолчанию, но явная запись документирует твоё намерение и защищает код, который работает на старых версиях.
<?php
declare(strict_types=1);
$comment = $_GET['comment'] ?? '';
echo '<div>' . $comment . '</div>';
function e(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
echo '<div>' . e($comment) . '</div>';CSRF использует один простой факт: браузер автоматически прикрепляет куки сессии к любому запросу к твоему домену - даже к запросу, инициированному со стороннего сайта. Поэтому враждебная форма может отправить /account/delete от имени сессии жертвы. Классическая защита - это токен синхронизации: случайный секрет, который хранится в сессии и обязателен в каждом изменяющем запросе. Сторонний сайт не может ни прочитать его, ни угадать, а значит, и передать.
<?php
session_start();
if (!isset($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
&& hash_equals($_SESSION['csrf'], $sent);
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
http_response_code(403);
exit('Invalid CSRF token');
}
?>
<form method="post">
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<button>Delete my account</button>
</form>SQL-инъекция устроена так же: склеивание пользовательского значения с запросом равносильно тому, чтобы дать пользователю писать SQL - ' OR 1=1 -- превращает фильтр во всю таблицу целиком. Решение - не экранирование, а подготовленный запрос: текст запроса (с плейсхолдерами вроде :email) и значения передаются движку раздельно, поэтому он в принципе не может спутать данные с кодом. Экспертная деталь: с MySQL драйвер PDO по умолчанию эмулирует подготовленные запросы, экранируя на стороне клиента; выстави PDO::ATTR_EMULATE_PREPARES в false, чтобы получить действительно нативные подготовленные запросы.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
'CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL
)'
);
$email = $_GET['email'] ?? '';
$stmt = $pdo->prepare(
'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
var_dump($stmt->fetch(PDO::FETCH_ASSOC));