<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · JUNIOR ЧАСТО ПИТАЮТЬ

Що таке prepared statements і як PDO захищає від SQL-ін'єкцій?

Prepared statement - це запит із плейсхолдерами (`?` або `:name`), у якому дані передаються окремо від тексту SQL, тому вони ніколи не парсяться як команда. Захист працює саме через це розділення, а не через екранування лапок; у PDO MySQL за замовчуванням увімкнена емуляція, тому перевіряйте `PDO::ATTR_EMULATE_PREPARES` і завжди задавайте `charset` у DSN.

Чому `WHERE id = '$id'` небезпечно, якщо я прогнав `$id` через екранування?
У чому різниця між `PDO::prepare()` і `PDO::query()`?
Чому `LIMIT :limit` падає з синтаксичною помилкою, хоча значення я передав числом?
PDO за замовчуванням реально надсилає запит із плейсхолдерами на сервер MySQL?
PDO SQL injection prepared statements security MySQL bindValue

Prepared statement розводить дві речі, які в конкатенації злипаються: текст запиту й значення. Спершу на сервер іде SQL із плейсхолдерами: ? для позиційних або :email для іменованих. Сервер його парсить, будує план і запамʼятовує під дескриптором. Далі execute() надсилає самі значення окремим повідомленням протоколу. Коли дані нарешті зʼявляються, структура запиту вже зафіксована: рядок x' OR '1'='1 стає значенням для порівняння з колонкою email, а не новою умовою. Екранувати тут нічого й нікому, бо дані взагалі не проходять через парсер SQL.

Тепер деталь, на якій сиплються junior-відповіді. У драйвері MySQL PDO за замовчуванням не робить нічого з описаного вище: PDO::ATTR_EMULATE_PREPARES там true. PDO сам розбирає текст запиту, підставляє значення в PHP (рядки - через екранування з урахуванням кодування зʼєднання) і відправляє готовий SQL. На практиці це теж безпечно, але вже з умовою: кодування має бути відоме PDO. Якщо в DSN немає charset=utf8mb4, а ви виставляєте кодування запитом SET NAMES, PDO про зміну не дізнається і екрануватиме за старим; для багатобайтових кодувань на кшталт GBK це відома дірка. Причин вимкнути емуляцію більше: з нативними prepared MySQL не приймає кілька команд в одному запиті, план парситься один раз на кілька execute(), а з PHP 8.1 драйвер повертає нативні типи (int для INT, float для DECIMAL лишається рядком) замість того, щоб перетворювати все на рядки. Платите за це другим round-trip і кількома обмеженнями: LIMIT вимагає явного PDO::PARAM_INT, а той самий іменований плейсхолдер двічі в запиті використати не вийде, доведеться завести :from1 і :from2.

Питання «чому конкатенація небезпечна навіть з екрануванням» має відповідь коротшу, ніж очікують. Екранування працює тільки в одному контексті: усередині рядкового літерала. У запиті WHERE id = $id лапок немає взагалі, тому будь-яка функція екранування пропустить 5 OR 1=1 без змін - там немає ні лапок, ні бекслешів, нічого, до чого їй причепитися. Далі вмикається людський фактор: у запиті з вісьмома параметрами достатньо забути обгортку в одному місці, і код виглядатиме нормально, а тест на щасливому шляху пройде. addslashes() не знає нічого про кодування зʼєднання і для цієї задачі непридатний у принципі. PDO::quote() кращий, бо знає драйвер і кодування, але лишає ту саму проблему контексту й ту саму можливість забути виклик. Плейсхолдер помилки такого класу робить неможливими: або значення передане параметром, або ви його не передали і запит не виконається.

Межа захисту проходить там, де користувацькі дані мають стати частиною структури, а не значенням. Назву колонки для ORDER BY, напрямок сортування, назву таблиці, набір полів у SELECT плейсхолдером не передати, і причина тут не в обмеженнях PDO: на момент парсингу ці частини вже мають бути відомі. Працює тільки зіставлення зі списком дозволених значень, як у прикладі з $allowed. Так само зі списком для IN: один плейсхолдер приймає одне значення, тому '1,2,3' MySQL приведе до числа 1 і поверне неправильну вибірку без жодної помилки. Плейсхолдери треба згенерувати по кількості елементів, а порожній масив перехопити до запиту.

І про те, що prepared statements не роблять. Вони не валідують дані: email довжиною 5000 символів пройде параметром так само спокійно, як і нормальний, тому валідація лишається окремим шаром. Не рятують вони й від ін'єкції другого порядку, коли ви коректно зберегли рядок параметром, а через тиждень прочитали його з бази і склеїли в новий запит: для конкатенації немає різниці, звідки взявся рядок. Прав доступу вони теж не замінюють, і акаунт застосунку з DROP та FILE перетворює будь-яку помилку в іншому місці на катастрофу. Мінімальний робочий набір, який варто назвати на співбесіді, виглядає так: ERRMODE_EXCEPTION, EMULATE_PREPARES => false, charset у DSN, параметри на всі значення, whitelist на ідентифікатори, окремий користувач БД з правами рівно під потреби застосунку.

declare(strict_types=1);

$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4', // charset саме тут, не через SET NAMES
    'app',
    $password,
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_EMULATE_PREPARES   => false,  // нативний prepare на боці MySQL
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ],
);

// Небезпечно: значення потрапляє в текст запиту до парсингу.
// $email = "x' OR '1'='1" перетворює умову на завжди істинну,
// а для числового id лапок немає взагалі - екранування там нічого не робить.
// $pdo->query("SELECT * FROM users WHERE email = '$email'");

$stmt = $pdo->prepare(
    'SELECT id, email FROM users WHERE email = :email AND status = :status LIMIT :limit'
);
$stmt->bindValue(':email', $email);                  // за замовчуванням PARAM_STR
$stmt->bindValue(':status', 'active');
$stmt->bindValue(':limit', 20, PDO::PARAM_INT);      // без PARAM_INT буде LIMIT '20'
$stmt->execute();
$users = $stmt->fetchAll();

// IN: по одному плейсхолдеру на елемент, інакше '1,2,3' стане одним значенням
$ids = [7, 12, 30];
$in = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, title FROM orders WHERE id IN ($in)");
$stmt->execute($ids);                                // масив біндить усе як рядки - для WHERE це ок

// Назву колонки плейсхолдером не передати: тільки whitelist
$allowed = ['created_at', 'total'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';
$stmt = $pdo->prepare("SELECT id FROM orders ORDER BY $sort DESC LIMIT 50");
$stmt->execute();
Що плейсхолдер підставляє значення, а не фрагмент SQL: `1 OR 1=1` у параметрі стане рядком-значенням, а не умовою.
Що `PDO::ATTR_EMULATE_PREPARES` у драйвері MySQL за замовчуванням `true`, і тоді підстановку робить сам PDO на боці PHP, а не сервер.
Що екранування рятує лише всередині лапок і лише для правильного кодування: `WHERE id = $id` без лапок ламається навіть з ідеальним escape.
Що імена таблиць, колонок і напрямок `ORDER BY` плейсхолдером не передати - тільки whitelist.
Різницю між `bindValue()` (значення копіюється одразу) і `bindParam()` (звʼязується змінна за посиланням, читається на `execute()`).
Вважати, що `prepare()` сам по собі захищає: якщо ви склеїли рядок і передали його в `prepare()`, ін'єкція вже в тексті запиту.
Ставити `PDO::quote()` або `addslashes()` замість параметрів і вважати задачу закритою.
Не задавати `charset=utf8mb4` у DSN і покладатися на `SET NAMES` через `query()`: емульовані плейсхолдери екрануватимуть у неправильному кодуванні.
Передавати `LIMIT` через `execute([$limit])`: масив біндить усе як `PDO::PARAM_STR`, і в SQL потрапляє `LIMIT '10'`.
Робити `IN (:ids)` з рядком `'1,2,3'` - це одне значення, а не список; плейсхолдери треба згенерувати по одному на елемент.
Викликати `bindParam()` у циклі з тимчасовою змінною і дивуватися, що всі рядки записались з останнім значенням.
ПОРАДА

Відповідайте через механіку, а не через слово «безпечно»: «сервер отримує текст запиту з плейсхолдерами й окремо - значення; після парсингу структура запиту вже зафіксована, тому значення не може стати командою». Потім одразу додайте, що в PDO MySQL це за замовчуванням емуляція, і покажіть свій набір опцій підключення - `ERRMODE_EXCEPTION`, `EMULATE_PREPARES => false`, `charset` у DSN. Це відрізняє людину, яка читала документацію, від людини, яка копіювала конфіг.

оновлено 8 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Захист дає розділення коду й даних: сервер парсить запит із плейсхолдерами, а значення приходять окремим повідомленням і вже не можуть змінити структуру. Екранування (варіант 1) - це те, що робить емуляція, і воно лише наближення до цієї моделі; фільтрації за ключовими словами PDO не робить.