Что защищает 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]
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]
Prepared statement отделяет структуру SQL-запроса от значений, которые приходят из внешнего мира.
Quick recall
Почему запрос ниже уязвим для SQL injection? ```php $email = $_POST['email'] ?? ''; $sql = "SELECT id, email FROM users WHERE email = '$email'"; $user = $pdo->query($sql)->fetch(); ```

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.

Quick recall
Найди баг безопасности: почему этот код всё ещё плохой, хотя использует `prepare()`? ```php $stmt = $pdo->prepare("SELECT id FROM users WHERE email = '$email'"); $stmt->execute(); ```

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,
]);
Quick recall
Когда в PDO удобнее named placeholders, а когда positional placeholders требуют особой осторожности?

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; права базы — минимальные для задачи.

См. также

Sources

  1. PHP Manual: PDO::prepare
  2. PHP Manual: PDOStatement::execute
  3. PHP Manual: PDOStatement::bindParam
  4. PHP Manual: PDOStatement::bindValue
  5. OWASP Cheat Sheet Series: SQL Injection Prevention