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