SQL-инъекции и подготовленные запросы в PDO

Вопрос, на котором собеседование либо продолжается, либо заканчивается.

SQL-инъекция — уязвимость, при которой данные пользователя попадают в текст запроса и меняют его смысл. Лечится не экранированием, а разделением кода и данных: подготовленным запросом.

Вопрос 1: «Что не так с этим кодом?»

$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = " . $id;
$rows = $pdo->query($sql)->fetchAll();

Посмотрим, во что превращается запрос, если пользователь передаст не число:

<?php
$id = "1 OR 1=1 --";
echo "SELECT * FROM users WHERE id = " . $id, "\n";

$login = "admin' --";
echo "SELECT * FROM users WHERE login = '" . $login . "' AND pass = 'x'", "\n";

Вывод:

SELECT * FROM users WHERE id = 1 OR 1=1 --
SELECT * FROM users WHERE login = 'admin' --' AND pass = 'x'

Первый запрос вернёт всю таблицу, второй — авторизует под администратором без пароля: два дефиса начинают комментарий, и проверка пароля исчезает. Проверим на живой таблице, что делает подставленное условие:

CREATE TABLE users (
  id       INTEGER PRIMARY KEY AUTOINCREMENT,
  login    TEXT,
  is_admin INTEGER
);

INSERT INTO users (login, is_admin) VALUES
  ('anna', 0),
  ('admin', 1),
  ('petr', 0);

-- то, что видит база после подстановки "1 OR 1=1"
SELECT * FROM users WHERE id = 1 OR 1=1;

Вместо одной строки возвращаются все три. Именно так утекают базы пользователей.

Вопрос 2: «Как правильно?»

Подготовленный запрос отправляется в СУБД отдельно от значений: сервер разбирает структуру запроса заранее, а данные подставляются на уровне протокола и уже никогда не могут стать частью синтаксиса.

<?php
$dsn = 'mysql:host=localhost;dbname=shop;charset=utf8mb4';
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // ошибки как исключения
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,                  // настоящие prepared statements
]);

// именованные плейсхолдеры
$stmt = $pdo->prepare('SELECT * FROM users WHERE login = :login AND active = :active');
$stmt->execute(['login' => $login, 'active' => 1]);
$user = $stmt->fetch();

// позиционные плейсхолдеры
$stmt = $pdo->prepare('INSERT INTO orders (user_id, total) VALUES (?, ?)');
$stmt->execute([$userId, $total]);
echo $pdo->lastInsertId();

Три настройки в конструкторе — то, что интервьюер хочет услышать обязательно:

  • ERRMODE_EXCEPTION — иначе ошибки запроса молча возвращают false, и код продолжает работать с мусором.
  • EMULATE_PREPARES => false — по умолчанию для MySQL PDO эмулирует подготовку: подставляет значения в строку сам, на стороне PHP. Это работает, но настоящие серверные prepared statements надёжнее и корректнее возвращают типы столбцов.
  • charset=utf8mb4 прямо в DSN — кодировка должна задаваться соединением, а не запросом SET NAMES.

Вопрос 3: «Что нельзя передать плейсхолдером?»

Любимый уточняющий вопрос. Плейсхолдер — это значение, а не кусок синтаксиса. Нельзя биндить имя таблицы, имя столбца, направление сортировки, LIMIT в эмулированном режиме и список значений для IN.

// НЕ РАБОТАЕТ: имя столбца и ORDER BY — не значения
$stmt = $pdo->prepare('SELECT * FROM users ORDER BY :column :dir');

// правильно: белый список
$allowed = ['name', 'created_at', 'total'];
$column  = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'name';
$dir     = ($_GET['dir'] ?? '') === 'desc' ? 'DESC' : 'ASC';
$stmt    = $pdo->prepare("SELECT * FROM users ORDER BY $column $dir");

// список для IN: генерируем плейсхолдеры по числу элементов
$ids = [3, 7, 12];
$in  = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ($in)");
$stmt->execute($ids);

Отдельно стоит сказать про PDO::quote() и mysqli_real_escape_string(): они существуют, но полагаться на них не нужно. Экранирование зависит от кодировки соединения и легко ломается при ошибке в настройке — исторические обходы через многобайтовые кодировки именно так и работали. Подготовленный запрос лишён этого класса проблем.

Типичные ошибки кандидатов

  • Считают, что mysqli_real_escape_string() или addslashes() — достаточная защита.
  • Используют prepare(), но всё равно склеивают строку внутри запроса: prepare("... WHERE id = $id").
  • Не знают про эмуляцию подготовки в PDO и уверены, что она всегда серверная.
  • Забывают включить ERRMODE_EXCEPTION и не понимают, почему ошибки запросов не видны.
  • Пытаются биндить имя таблицы или ORDER BY вместо белого списка.
  • Считают ORM абсолютной защитой: DB::raw() и сырые фрагменты в query builder уязвимы так же, как обычная конкатенация.

Как ответить кратко

«SQL-инъекция возникает, когда данные пользователя попадают в текст запроса. Защита — подготовленные запросы: prepare() с плейсхолдерами и execute() с массивом значений, при этом данные уходят в СУБД отдельно от кода. PDO настраиваю с ERRMODE_EXCEPTION, EMULATE_PREPARES=false и charset=utf8mb4. Плейсхолдером нельзя передать имя таблицы, столбца или направление сортировки — для них белый список допустимых значений».

Проверьте себя
1. Почему подготовленный запрос защищает от SQL-инъекции?
AОн экранирует кавычки в значениях
BОн ограничивает длину передаваемых значений
CОн шифрует передаваемые параметры
DСтруктура запроса разбирается СУБД отдельно от данных, поэтому значение не может стать частью синтаксиса
2. Что делает опция PDO::ATTR_EMULATE_PREPARES => false?
AЗаставляет использовать настоящие серверные prepared statements вместо подстановки значений на стороне PHP
BОтключает подготовленные запросы полностью
CВключает кэширование результатов запросов
DПереводит ошибки в исключения
3. Как безопасно подставить в запрос имя столбца для ORDER BY, пришедшее из GET-параметра?
AПередать его плейсхолдером :column
BПроверить по белому списку допустимых имён и подставить в строку
CОбработать через PDO::quote()
DПрименить addslashes()