Довіряй, але перевіряй: захист від SQL-інєкцій

Поза всяких сумнівів, SQL-ін’єкції є одним з найпоширеніших способів злому сайту. Чи не перше, що намагається провернути зломщик – тестування популярних ін’єкцій. У цьому невеликому пості ми коротко розглянемо історію питання, методи боротьби з ін’єкціями, а також напишемо невеликий PHP-клас обгортку для PDOStatement для безпечного підключення та взаємодії з MySQL-сервером (MySQL в даному випадку наводиться лише з причини найбільшої поширеності, при бажанні все нижченаведене може бути адаптовано і на інші СУБД).

Суть питання:

Що, по суті, є ін’єкція? Початківець php-розробник, тільки що приступив до вивчення MySQL, найімовірніше, буде конструювати запити приблизно наступним чином:

$query = “SELECT name FROM mytable WHERE id=” . $_GET[‘id’];В даному випадку, PHP запитує в MySQL значення name відповідне id, отриманим через GET. Ймовірно, id був введений з форми або виник в результаті переходу користувача за посиланням виду http://somedomain.com/?id=5. Сконструйований таким чином запит зазвичай відправляється на сервер з використанням популярної php-бібліотеки mysqli.

У разі доброчесної користувача, скрипт спрацює саме так, як і замислювалося. Що ж спробує зробити зловмисник? Він не стане відправляти на сервер числове значення. Замість цього він спробує поставити після числового значення крапку з комою (символ завершення SQL-запиту, після чого напише свій, вже не має до спочатку задуманому нами запит. Щось на зразок того:

5; SELECT * FROM mytable;або ще страшніше:

5; DROP TABLE mytable;
В результаті, MySQL-сервер спочатку виконає наш запит, а слідом за ним – запит зловмисника. Таким чином, зломщик, в залежності від ситуації (і прав MySQL юзера, який використовується PHP) зробити майже все що завгодно: від отримання доступу до конфіденційної інформації до повного контролю над базою даних.

Як же розробнику захистити свій сайт від ін’єкцій?

Ручна перевірка користувальницьких даних. Зрозуміло, перевірка формату даних, що вводяться – перший найбільш очевидний варіант захисту від ін’єкцій. Замість того, щоб довіряти даних, введених ми будемо перевіряти їх на відповідність потрібного нам формату. Робити це можна десятком різних способів. Наприклад:

$query = “SELECT name FROM mytable WHERE id=” . intval($_GET[‘id’]);Або:

if( intval ( $_GET[‘id’] ) ) {
$query = “SELECT name FROM mytable WHERE id=” . intval($_GET[‘id’]);
} else {
exit(‘айайай!’);
}В даному випадку, отримана з GET-а інформація перед відправкою перевіряється на числове значення. Будь-яке значення, відмінне від числового, введене зловмисником не буде відправлено на MySQL-сервер. Для валідації більш складних значень можна використовувати регулярні вирази.
Іншим варіантом примітивної захисту є використання нативної функції mysqli_real string_escape() для запобігання проникнення «корварных» запитів. Головним мінусом такого підходу, є його крайня неавтоматизированность: нам доводиться перевіряти власне значення кожного разу, коли ми робимо запит. Можна, звичайно, використовувати готовий SQL-білдер з вбудованим захистом від SQL-ін’єкцій або написати свій. Однак, в даному пості ми пропонуємо дещо інший підхід, заснований на використанні PDO.

Використання PDOStatement

Починаючи з версії 5.1 в PHP доступний вбудований клас PDO (PHP Data Objects). Цей клас містить багатий набір методів для роботи з широким спектром баз даних. Незважаючи на поважний вік цього інструменту, багато розробники ним нехтує, воліючи «по-старому» користуватися бібліотекою mysqli, в тому числі і ми в DLE, але у нас є ряд важливих причин, DLE це старий скрипт, який з’явився задовго до PDO, і у нас є зобов’язання щодо сумісності зі старими версіями скрипта, так і по максимальному спрощенню процесу оновлення, для тих, хто користується іншими модулями. Плюс ми дуже ретельно підходимо до питань фільтрації вхідних даних. Але ви, на відміну від нас не такі старі «динозаври», тому головна думка, яку ми хочемо донести до вас полягає в наступному: пряме використання mysqli без будь-яких обгорток – прямий шлях до SQL-ін’єкцій, тому пишіть код відразу безпечним. У розробників, по суті, є тільки три виходи:

  • використовувати ручну перевірку даних
  • написати власну бібліотеку (або взяти готову) на основі mysqli з захистом від SQL-ін’єкцій
  • використовувати PDO

Саме про останній спосіб і піде розмова. У PDO є спеціальний набір методів, званий PDOStatement. Його сутність полягає в тому, що сам запит і значення стовпців або параметрів запиту передаються на MySQL-сервер окремо. Таким чином, ми отримуємо вбудований захист від ін’єкцій на всі випадки життя (ну або майже все). В силу того, що PDOStatement – досить громіздка штука (з точки зору рядків коду), найзручніше користуватися їй через самописную обгортку, написанням якої ми зараз і займемося. Щоб відразу привчати до хорошого, результати своєї діяльності ми оформимо у вигляді класу.

Наш клас буде містити всього 4 методу:

  • метод для з’єднання з базою
  • метод для перевірки наявності з’єднання
  • метод для відправки безпечного запиту через PDOStatement
  • метод розриву з’єднання з базою

Отже приступимо. Для роботи з базою нам знадобляться 6 властивостей:

class myClass {
private $host = “localhost”;
private $dbname = “dbname”;
private $user = “username”;
private $pass = “userpassword”;
private $charset = “utf8”;
private $pdo;
}Перші 5 містять хост, ім’я бази, логін, пароль і кодування і не потребують поясненні. У 6-е властивість ж знадобиться нам для зберігання об’єкта pdo. Насамперед, слід, звичайно, написати метод для з’єднання:

public function mysql_connect() {
$dsn = “mysql:host=” . $this->host . “;dbname=” . $this->dbname . “;charset=” . $this->charset;
$connopt = array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
);
$this->pdo = new PDO($dsn, $this->user, $this->pass, $connopt);
}Перший рядок містить набір параметрів для з’єднання з сервером. Масив $connopt задає різні режими роботи з PDO. Ці режими можуть бути використані для тонкого налаштування помилок і специфічних ситуацій. Ми не будемо вдаватися в подробиці використовуваних опцій в даному керівництві. Всі зацікавлені можуть звернутися до більш докладної документації по PHP. Тут же відзначимо, що в останній сходинці ми створюємо об’єкт для роботи з PDO, передаючи конструктору задані параметри, і пишемо цей об’єкт у наш властивість $pdo.

Отже, з базою ми з’єдналися. Напишемо метод для перевірки наявності з’єднання. З цією метою ми будемо використовувати метод getAttribute(PDO::ATTR_CONNECTION_STATUS). У разі нормального з’єднання він повертає рядок “hostname via TCP/IP”, де hostname – ім’я нашого хоста.

public function mysql_get_status() {
if(is_null($this->pdo)) {
return false;
} elseif ($this->pdo->getAttribute(PDO::ATTR_CONNECTION_STATUS) === $this->host . “via TCP/IP”) {
return true;
} else {
return false;
}
}Якщо властивість pdo порожньо – означає з’єднання не створено (або було зруйновано). Наступним етапом ми перевіряємо значення повертається getAttribute і порівнюємо його з нормальним. Якщо воно відрізняється – викидаємо в false. Якщо все пройшло гладко – повертаємо true. Якщо ні – false.

Тепер, власне, найцікавіше. Напишемо метод для відправки безпечного запиту до бази.
Наш метод буде мати три аргументи:

  • тіло самого запиту
  • асоціативний масив з набором значень
  • бінарний аргумент, що визначає потрібно повернути дані з бази. Для виконання SELECT’ів даний аргумент буде дорівнює true, а для INSERT’ів\UPDATE ов – false.

Отже, наш метод:

public function mysql_query($query, $placeholders = null, $select = true) {
if($this->mysql_get_status()) {
$stmt = $this->pdo->prepare($query);
if(!is_null($placeholders)) {
$stmt->execute($placeholders);
} else {
$stmt->execute();
}
if ($select) {
$arr = $stmt->fetchAll();
return $arr;
}
} else {
return false;
}
}Давайте розберемося, що ж тут відбувається. Перша умова необхідно для того, щоб намагатися виконувати запит тільки при наявності з’єднання з базою даних. У другому умови, ми перевіряємо наявність масиву значень (плейсхолдеров). Якщо масив не вказаний – значить ми виконуємо запит «як є». Якщо ж масив плейсхолдеров має місце бути, ми передаємо його методу execute() в якості аргументу. Методи prepare() і execute() – і є те, заради чого все складалося. Як ви могли помітити, при роботі з базою через PDO тіло запиту та його значення передаються PDO окремо один від одного. При цьому сам запит записується в наступному вигляді:

“SELECT name FROM mytable WHERE id = :user_id”Де :user_id – назва ключа в масиві, що передається в execute(). Тобто в явному вигляді відправлення запиту з використанням PDOStatement виглядає приблизно так:

$smtp = $pdo->prepare(“SELECT name FROM mytable WHERE id = :user_id”);
$stmt-execute( array (‘user_id’ => ‘5’ ) );Для руйнування з’єднання достатньо лише присвоїти null об’єкту pdo. Тому, метод для ліквідації з’єднання буде найкоротшим:

public function mysql_destroy() {
$this->pdo = null;
}Отже, зберемо наш клас воєдино:

class myClass {
private $host = “localhost”;
private $dbname = “dbname”;
private $user = “username”;
private $pass = “userpassword”;
private $charset = “utf8”;
private $pdo;
public function mysql_connect() {
$dsn = “mysql:host=” . $this->host . “;dbname=” . $this->dbname . “;charset=” . $this->charset;
$connopt = array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
);
$this->pdo = new PDO($dsn, $this->user, $this->pass, $connopt);
}
public function mysql_get_status() {
if(is_null($this->pdo)) {
return false;
} elseif ($this->pdo->getAttribute(PDO::ATTR_CONNECTION_STATUS) === $this -> host . “via TCP/IP”) {
return true;
} else {
return false;
}
}
public function mysql_query($query, $placeholders = null, $select = true) {
if($this->mysql_get_status()) {
$stmt = $this->pdo->prepare($query);
if(!is_null($placeholders)) {
$stmt->execute($placeholders);
} else {
$stmt->execute();
}
if ($select) {
$arr = $stmt->fetchAll();
return $arr;
}
} else {
return false;
}
}
public function mysql_destroy() {
$this->pdo = null;
}
}Приклад відправки безпечного запиту:
//встановлюємо з’єднання
$obj = new myClass;
$obj->mysql_connect();
//SELECT
$data = $obj->mysql_query(“SELECT name FROM mytable WHERE id = :user_id AND email=:email;”, array(‘user_id’ => ‘5’, ’email’ => ‘[email protected]’ ), TRUE);
print_r( $data );
//INSERT
$obj->mysql_query(“INSERT INTO mytable (‘name’, ’email’) VALUES (:name, :email);”, array( ‘name’ => ‘Іван’, ’email’ => ‘[email protected]’ ), ‘FALSE’);
//розриваємо з’єднання
$obj->mysql_destroy();На цьому поки все. Удачі вам і гарного настрою. Підписуйтесь на нашу сторінку в соціальній мережі “Вконтаке” https://vk.com/dlepage
18