Что защищает prepared statement
SQL injection появляется там, где SQL-код собирают строковой конкатенацией из внешних данных: $_GET, $_POST, cookie, CLI-аргументов, заголовков, импортируемых CSV. Атакующий пытается сделать так, чтобы его строка стала частью SQL-синтаксиса, а не обычным значением.
Классический уязвимый пример:
<?php
$email = $_POST['email'] ?? '';
$sql = "SELECT id, email FROM users WHERE email = '$email'";
$user = $pdo->query($sql)->fetch();Если в $email попадёт строка вроде x' OR '1'='1, итоговый SQL изменит смысл условия. Проблема не в том, что строка «плохая», а в том, что приложение смешало код и данные.
Prepared statement решает именно эту границу. Сначала приложение передаёт базе шаблон SQL, где структура запроса уже определена. Потом отдельно передаёт значения. База видит: вот SQL-код, а вот данные для параметров. Даже если значение выглядит как SQL, оно остаётся значением.
flowchart TD
A[HTTP/CLI input] --> B{Как строится запрос?}
B -->|Конкатенация| C[SQL-код и данные смешаны]
C --> D[Риск SQL injection]
B -->|prepare + parameters| E[SQL-шаблон фиксирует структуру]
E --> F[execute передаёт значения отдельно]
F --> G[База различает code и data]prepare() и execute()
В PDO подготовленный запрос обычно выглядит так:
<?php
$stmt = $pdo->prepare(
'SELECT id, email FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();PDO::prepare() возвращает PDOStatement: объект запроса. PDOStatement::execute() выполняет его с конкретными параметрами. Это не то же самое, что «экранировать строку и вставить её в SQL». При нормальном использовании параметр не становится куском SQL.
Важная ловушка: prepare() сам по себе не делает код безопасным, если вы уже вставили значение в строку запроса:
<?php
// Плохо: значение уже попало внутрь SQL.
$stmt = $pdo->prepare("SELECT id FROM users WHERE email = '$email'");
$stmt->execute();Правило простое: внешние значения не должны попадать в SQL-строку через конкатенацию или interpolation. Они должны доходить до базы через placeholders.
Named и positional placeholders
PDO поддерживает два стиля параметров. Named placeholders удобны в длинных запросах:
<?php
$stmt = $pdo->prepare(
'SELECT id, title
FROM posts
WHERE author_id = :author_id AND status = :status'
);
$stmt->execute([
'author_id' => $authorId,
'status' => 'published',
]);Positional placeholders короче, но требуют следить за порядком:
<?php
$stmt = $pdo->prepare(
'SELECT id, title
FROM posts
WHERE author_id = ? AND status = ?'
);
$stmt->execute([$authorId, 'published']);В одном statement нельзя смешивать :name и ?. Для читаемости в прикладном PHP чаще выбирают named placeholders, особенно когда параметров больше двух.
У named placeholders есть ещё одна практическая деталь: не рассчитывайте на повторное использование одного и того же имени несколько раз в одном SQL. Официальная модель PDO требует отдельный marker для каждого значения, если не включена эмуляция prepared statements. Надёжнее писать явно:
<?php
$stmt = $pdo->prepare(
'SELECT id
FROM users
WHERE email = :email OR backup_email = :backup_email'
);
$stmt->execute([
'email' => $email,
'backup_email' => $email,
]);execute() массивом, bindValue() и bindParam()
Для большинства обычных запросов достаточно передать массив в execute():
<?php
$stmt = $pdo->prepare(
'INSERT INTO logins (user_id, ip_address) VALUES (:user_id, :ip)'
);
$stmt->execute([
'user_id' => $userId,
'ip' => $ipAddress,
]);Но у такого способа есть ограничение: значения из массива execute() передаются как input-only параметры и обычно трактуются как строки, если драйверу не указано иначе. В большинстве WHERE email = :email это нормально. Для LIMIT, boolean-флагов или строгих типов драйвера бывает полезнее bindValue():
<?php
$stmt = $pdo->prepare(
'SELECT id, email FROM users ORDER BY id DESC LIMIT :limit'
);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();bindValue() привязывает конкретное значение в момент вызова. bindParam() привязывает переменную по ссылке, а значение берётся в момент execute():
<?php
$stmt = $pdo->prepare(
'INSERT INTO events (name) VALUES (:name)'
);
$name = 'registered';
$stmt->bindParam(':name', $name, PDO::PARAM_STR);
$name = 'confirmed';
$stmt->execute(); // в базу уйдёт confirmedИз-за этой ссылки bindParam() полезен для stored procedures и редких случаев с output/inout-параметрами. В обычном CRUD-коде execute([...]) или bindValue() читаются проще и дают меньше сюрпризов.
Что нельзя параметризовать
Placeholder представляет целый data literal: строку, число, NULL, binary value. Он не представляет имя таблицы, имя колонки, направление сортировки, оператор, список колонок или произвольный кусок SQL.
Так писать нельзя:
<?php
$stmt = $pdo->prepare('SELECT * FROM :table WHERE id = :id');
$stmt->execute(['table' => 'users', 'id' => 10]);И так тоже не надо:
<?php
$stmt = $pdo->prepare(
'SELECT id, email FROM users ORDER BY :sort :direction'
);Если часть SQL действительно должна быть динамической, её выбирают из whitelist, а значения всё равно передают параметрами:
<?php
$allowedSort = [
'email' => 'email',
'created' => 'created_at',
'id' => 'id',
];
$sort = $allowedSort[$_GET['sort'] ?? 'id'] ?? 'id';
$direction = ($_GET['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
$sql = "SELECT id, email, created_at
FROM users
WHERE status = :status
ORDER BY {$sort} {$direction}";
$stmt = $pdo->prepare($sql);
$stmt->execute(['status' => 'active']);Это место напрямую связано с GET, POST и фильтрация ввода: фильтрация и allowlist нужны не вместо prepared statements, а рядом с ними. Prepared statements защищают значения. Allowlist защищает те части SQL, которые параметром быть не могут.
IN (...), LIKE и другие частые случаи
Один placeholder не разворачивается в несколько значений. Для IN placeholders нужно создать по количеству элементов:
<?php
$ids = [12, 15, 19];
$marks = implode(', ', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, email FROM users WHERE id IN ({$marks})");
$stmt->execute($ids);Если список может быть пустым, обработайте это отдельно: WHERE id IN () — невалидный SQL во многих СУБД.
Для LIKE wildcard-символы добавляют к значению, а не к placeholder внутри SQL-строки:
<?php
$q = trim($_GET['q'] ?? '');
$stmt = $pdo->prepare(
'SELECT id, title FROM posts WHERE title LIKE :q ORDER BY id DESC'
);
$stmt->execute(['q' => '%' . $q . '%']);Если пользовательский поиск должен воспринимать % и _ как обычные символы, а не wildcard, понадобится отдельное экранирование для LIKE и конструкция ESCAPE. Это уже не защита от SQL injection, а корректная семантика поиска.
Эмуляция prepared statements
У PDO есть режим PDO::ATTR_EMULATE_PREPARES. Когда он включён, PDO может не отдавать подготовку запроса серверу базы, а сам переписывать placeholders перед отправкой SQL. Это бывает совместимостью с драйвером, но меняет момент проверки SQL и поведение некоторых edge cases.
В практической фабрике подключения часто явно ставят:
<?php
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);Это хорошо сочетается с тем, что было задано в PDO и подключение к базе: ошибки идут через исключения, строки возвращаются как ассоциативные массивы, а подготовка запроса по возможности остаётся на стороне драйвера и СУБД.
Prepared statements — не вся безопасность базы
Prepared statements резко уменьшают риск SQL injection, но они не проверяют бизнес-правила. Если пользователь не должен видеть чужой заказ, запрос WHERE id = :id всё равно должен учитывать владельца: WHERE id = :id AND user_id = :user_id. Если DB-пользователь приложения имеет права DROP TABLE, prepared statements не спасут от последствий другой уязвимости. Поэтому рядом нужны минимальные права, нормальная обработка ошибок из Транзакции и режимы ошибок PDO, безопасная конфигурация из Конфигурация безопасности PHP и аккуратная работа с HTTP-вводом.
Короткая формула для ревью PHP-кода: SQL-структура может быть строкой в коде; данные идут через parameters; dynamic identifiers — только через whitelist; права базы — минимальные для задачи.
См. также
- PDO и подключение к базе — DSN, драйверы, fetch modes и базовая фабрика подключения.
- Транзакции и режимы ошибок PDO — как ошибки PDO попадают в
PDOExceptionи что происходит внутри транзакций. - GET, POST и фильтрация ввода — валидация внешних данных до попадания в SQL-логику.
- Ошибки, исключения и Throwable — общая модель исключений PHP.
- Конфигурация безопасности PHP — production-настройки, которые уменьшают поверхность атаки.
- XSS, экранирование вывода и шаблоны — соседняя граница безопасности: SQL защищают параметры, HTML защищает context-aware output encoding.