Kodokon kodokon.com

Безопасность в вебе: XSS, CSRF и инъекции

Разбери XSS, CSRF и SQL-инъекции по косточкам, чтобы точно понять, что именно блокируют htmlspecialchars, токены и подготовленные запросы.

12 мин · 3 вопросов

Открыть этот урок в Kodokon

У трёх больших семейств веб-уязвимостей - XSS, CSRF и инъекций - одна и та же первопричина: данные, переданные пользователем, в итоге интерпретируются как код (HTML, SQL или законный запрос). Начнём с XSS: если ты выводишь значение из $_GET без экранирования, злоумышленник может внедрить тег <script> и выполнить JavaScript в браузерах твоих посетителей - украсть сессии, совершить действия без их ведома. При ?comment=<script>alert(1)</script> первый вывод ниже запускает скрипт, а второй его обезвреживает. Здесь важны два флага: ENT_QUOTES преобразует и одинарные кавычки (иначе атрибут, ограниченный одинарными кавычками, остаётся уязвимым для внедрения), а ENT_SUBSTITUTE заменяет некорректные последовательности UTF-8 вместо того, чтобы вернуть пустую строку. Начиная с PHP 8.1 оба флага наконец стали значениями по умолчанию, но явная запись документирует твоё намерение и защищает код, который работает на старых версиях.

PHP
<?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 от имени сессии жертвы. Классическая защита - это токен синхронизации: случайный секрет, который хранится в сессии и обязателен в каждом изменяющем запросе. Сторонний сайт не может ни прочитать его, ни угадать, а значит, и передать.

HTML
<?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
<?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));
Значение никогда не попадает в текст SQL: инъекция невозможна.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Зачем передавать флаг ENT_QUOTES в htmlspecialchars?
    • Чтобы преобразовать и одинарные кавычки: без этого атрибут, ограниченный одинарными кавычками, остаётся уязвимым для внедрения
    • Чтобы принудительно преобразовать строку в UTF-8
    • Чтобы вырезать теги script из содержимого
    • Чтобы ускорить экранирование длинных строк
  2. Почему CSRF-токен сравнивают через hash_equals, а не через ===?
    • hash_equals сравнивает за постоянное время, что не даёт угадывать токен байт за байтом, замеряя время ответа
    • hash_equals автоматически пересчитывает хеш токена перед сравнением
    • === падает, как только у двух строк разные кодировки
  3. Что делает подготовленный запрос неуязвимым для SQL-инъекции?
    • Он автоматически экранирует кавычки, которые содержатся в значениях
    • Запрос и значения отправляются движку раздельно, и движок никогда не интерпретирует значения как SQL
    • Он проверяет, что каждое значение соответствует типу целевого столбца
    • Он ограничивает выполнение одной SQL-инструкцией