Pull to refresh

PHP класс для удобной и безопасной работы с MySQL

Reading time 9 min
Views 116K
После написания статьи про защиту от инъекций я взялся за написание класса, реализующего изложенные в ней идеи.
А точнее, поскольку ключевой функционал уже использовался в рамках рабочего фремворка, я занялся выделением его в самостоятельный класс. Пользуясь случаем, хочу поблагодарить участников PHPClub-а за помощь в исправлении нескольких критических ошибок и полезные замечания. Ниже я постараюсь описать основные особенности, но сначала небольшой
дисклеймер
Есть несколько способов работы с SQL — можно использовать квери-билдер, можно ORM, можно работать с чистым SQL. Я избрал последний вариант, потому что мне он ближе. Я совсем не считаю первые два плохими. Просто лично мне всегда было тесно в их рамках. Но я ни в коем случае не утверждаю, что мой вариант лучше. Это просто ещё один вариант. Который можно использовать, в том числе, и при написании ORM-а. В любом случае, я считаю, что наличие безопасного способа работать с чистым SQL не может принести какой-либо вред. Но при этом, возможно, поможет последним оставшимся приверженцам использования mysql_* в коде приложения, отказаться, наконец, от этой порочной практики.

В двух словах, класс строится вокруг набора функций-хелперов, позволяющих выполнять большинство операций с БД в одну строку, обеспечивая при этом (в отличие от стандартных API) полную защиту от SQL инъекций, реализованную с помощью расширенного набора плейсхолдеров, защищающих любые типы данных, которые могут попадать запрос.
В основу класса положены три базовых принципа:
  1. 100% защита от SQL инъекций
  2. При этом защита очень удобная в применении, делающая код короче, а не длиннее
  3. Универсальность, портабельность и простота освоения

Остановлюсь чуть подробнее на каждом из пунктов.

Безопасность


обеспечивается теми самыми двумя правилами, которые я сформулировал в статье:
  1. Любые — без исключений! — динамические элементы попадают в запрос только через плейсхолдеры.
  2. Всё, что не получается подставить через плейсхолдеры — прогоняется сначала через белый список.

К сожалению, стандартные библиотеки не предоставляют полной защиты от инъекций, защищая с помощью prepared statements только строки и числа.
Поэтому, чтобы сделать защиту полной, пришлось отказаться от заведомо ограниченной концепции prepared statements в пользу более широкого понятия — плейсхолдеров. Причём плейсхолдеров типизованных (эта вещь всем нам известна по семейству функций printf(): %d — плейсхолдер, который подсказывает парсеру, как обрабатывать подставляемое значение, в данном случае — как целое число). Нововведение оказалось настолько удачным, что разом решило множество проблем и значительно упростило код. Подробнее о типизованных плейсхолдерах я напишу ниже.
Поддержка же фильтрации по белым спискам обеспечивается двумя функциями, несколько притянутыми за уши, но, тем не менее, необходимыми.

Удобство и краткость кода приложения


Здесь мне также здорово помогли типизованные плейсхолдеры, которые позволили сделать вызовы функций однострочными, передавая сразу и запрос, и данные для него. Плюс набор хелперов, напоминающих таковые в PEAR::DB — функций, сразу возвращающих результат нужного типа. Все хелперы организованы по одной и той же схеме: в функцию передаётся один обязательный параметр — запрос с плейсхолдерами, и сколько угодно опциональных параметров, количество и порядок которых должны совпадать с количеством и порядком расположения плейсхолдеров в запросе. У функций семейства Ind используется ещё один обязательный параметр — имя поля, по которому осуществляется индексация возвращаемого массива.
Исходя из своего опыта, я пришёл к следующему набору возвращаемых значений (и, как следствие — хелперов):
  • query() — возвращает mysqli resource. Может использоваться традиционно, с fetch() и т.д.
  • getOne() — возвращает скаляр, первый элемент первой строки результата
  • getRow() — одномерный массив, первую строку результата
  • getCol() — одномерный массив скаляров — колонку таблицы
  • getAll() — двумерный массив, индексированный числами по порядку
  • getInd() — двумерный массив, индексированный значениями поля, указанного первым параметром
  • getIndCol() — массив скаляров, индексированный полем из первого параметра. Незаменимо для составления словарей вида key => value

В итоге большинство обращений к БД сводится одно-двух строчным конструкциям (вместо 5-10 при традиционном подходе):
$data = $db->getAll("SELECT * FROM ?n WHERE mod=?s LIMIT ?i",$table,$mod,$limit);

В этом коде есть только необходимые и значащие элементы, но нет ничего лишнего и повторяющегося. Все потроха аккуратно упрятаны внутрь класса: хелпер getAll() позволяет получить сразу нужный результат без написания циклов в коде приложения, а типизованные плейсхолдеры позволяют безопасно добавлять в запрос динамические элементы любых типов без прописывания привязок (bind_param) вручную. Extra DRY код! В случаях использования плейсхолдеров ?a и ?u разница в количестве кода становится ещё больше:
$data = $db->getAll("SELECT * FROM table WHERE category IN (?a)",$ids);

Универсальность и простота освоения


стоят на трех китах:
  1. Очень маленький API — пол-дюжины плейсхолдеров и столько же хелперов.
  2. Мы работаем со старым добрым SQL, который не надо заново учить.
  3. На первый взгляд незаметная, но невероятно полезная функция parse(), которая изначально предназначалась только для отладки, но в итоге выросла до ключевого элемента при составлении сложных запросов.

В итоге все сложносочинённые запросы собираются по-старинке — например в цикле — но при этом при соблюдении всех правил безопасности!
Приведу небольшой пример (примеры посложнее можно найти в документации по ссылке внизу статьи):
Довольно частый случай, когда нам надо добавить в запрос условие при наличии переменной

$sqlpart = '';
if (!empty($var)) {
    $sqlpart = $db->parse(" AND field = ?s", $var);
}
$data = $db->getAll("SELECT * FROM table WHERE a=?i ?p", $id, $sqlpart);

Здесь важно отметить несколько моментов.
Во-первых поскольку мы не связаны родным API, никто не запрещает нам пропарсить не весь запрос целиком, а только его часть. Это оказывается супер-удобно для запросов, собирающихся в соответствии какой-либо логикой: мы парсим только часть запроса, а затем она подставляется в основной запрос через специальный «холостой» плейсхолдер, чтобы избежать повторного парсинга (и соблюсти правило «любые элементы подставляются только через плейсхолдер»).
Но, к сожалению, это является слабым местом всего класса. В отличие от всех остальных плейсхолдеров (которые, даже будучи использованы неверно, никогда не приведут к инъекции) некорректное использование плейсхолдера ?p может к ней привести.
Однако защита от дурака сильно усложнила бы класс, но при этом все равно никак не защитила бы от тупой вставки переменной в строку запроса. Поэтому я решил оставить как есть. Но если вы знаете способ, как без слишком большого оверинжиниринга решить эту проблему — я был бы благодарен за идеи.

Тем не менее, в итоге мы получили мощный и лёгкий генератор запросов, который с лихвой оправдывает этот небольшой недостаток.
Мощный потому, что мы не ограничены синтаксисом квери-билдера, «SQL-ем, написанным на PHP» — мы пишем чистый SQL.
Лёгкий потому, что весь API составления запросов состоит из полудюжины плейсхолдеров и функции parse()
Вот мой любимый пример — вставка с использованием функций Mysql
$data = array('field'=>$value,'field2'=>$value);
$sql  = "INSERT INTO table SET ts=unix_timestamp(), ip=inet_aton(?s),?u";
$db->query($sql, $ip, $data);

С одной стороны, мы сохраняем синтаксис SQL, с другой — делаем его безопасным, а с третьей — капитально сокращаем количество кода.

Подробнее о типизованных плейсхолдерах


Сначала ответим на вопрос, почему плейсхолдеры вообще?
Это, в общем, уже общее место, но, тем не менее, повторюсь — любые динамические данные должны попадать в запрос только через плейсхолдеры по следующим причинам:
  • самое главное — безопасность. Добавив переменную черз плейсхолдер, мы можем быть уверены в том, что она будет корректно отформатирована.
  • локальность форматирования. Это не менее важный момент. Во-первых, данные форматируются непосредственно перед попаданием в запрос, и не затрагивают исходную переменную, которая потом может быть использована где-то ещё. Во-вторых, данные форматируются ровно там, где нужно, а не до начала работы скрипта, как при magic quotes, и не в десяти возможных местах кода несколькими разработчиками, каждый из которых может понадеяться на другого.

Развивая эту концепцию далее, мы приходим к мысли, что пейсхолдеры обязательно должны быть типизованными. Но почему?
Тут я бы хотел ненадолго остановиться, и проследить историю развития программистской мысли в области защиты от инъекций.
Сначала был хаос — вообще никакой защиты, пихаем всё как есть.
Дальше стало не сильно лучше, с парадигмой «искейпим всё, что пришло в скрипт от пользователя» и кульминацией в виде magic quotes.
Дальше лучшие умы пришли к тому, что правильно говорить не об экранировании, а о форматировании. Поскольку форматирование не всегда сводится к одному искейпингу. Так в PDO появился метод quote(), который делал законченное форматирование строки — не только экранировал в ней спецсимволы, но и заключал её в кавычки, не надеясь на программиста. В итоге, даже если программист использовал эту функцию не на месте (например, для числа), то инъекция всё равно не проходила (а в случае с голым экранированием через mysql_real_escape_string она легко проходит, если мы поместили в запрос число, не заключая его в кавычки). Будучи же использованной для форматирования идентификатора, эта функция приводила к ошибке на этапе разработки, что подсказывало автору кода о том, что он немножечко неправ.
К сожалению, на этом авторы PDO и остановились, поскольку в головах разработчиков до сих пор крепко сидит мысль о том, что форматировать в запросе надо только строки. Но на самом деле в запросе гораздо больше элементов самых различных типов. И для каждого нужен свой собственный тип форматирования! То есть, единственный метод quote() нас никак не устроит — нужно много разных quotes. Причём не в качестве исключения, «нате вам quoteName()», а как одна из главных концепций: каждому типу — свой формат. Ну а раз типов форматирования оказывается много — тип надо как-то указывать. И типизованный плейсхолдер для этого подходит лучше всего.

Кроме того, типизованный плейсхолдер — это ОЧЕНЬ удобно!
Во-первых, потому что становится ненужным специальный оператор для привязки значения к плейсхолдеру (но при этом сохраняется возможность указать тип передаваемого значения!)
Во-вторых, раз уж мы изобрели типизованный плейсхолдер — мы можем налепить этих плейсхолдеров огромное количество, для решения множества рутинных задач по составлению SQL запросов.
В первую очередь сделаем плейсхолдер для идентификаторов — нам его отчаянно не хватает в реальной, а не воображаемой авторами стандартных API, жизни. Как только девелопер сталкивается с необходимостью динамически добавить в запрос имя поля — каждый начинает извращаться по-своему, кто в лес, кто по дрова. Здесь же всё унифицировано с остальными элементами запроса, и добавление идентификатора становится не сложнее добавления строки. Но при этом идентификатор форматируется не как строка, а в соответствии со своими собственными правилами — заключается в обратные кавычки, а внутри эти кавычки экранируются удвоением.
Дальше — больше. Следующая головная боль любого разработчика, когда-либо пытавшегося использовать стандартные prepared statements в реальной жизни — оператор IN(). Вуаля, у нас есть плейсхолдер и для этой операции! Подстановка массива становится не сложнее любых других элементов, плюс она унифицирована с ними — никаких отдельных функций, меняется всего лишь буква в плейсхолдере.
Точно таким же образом делаем и плейсхолдер для SET. Не удержусь и продемонстрирую, насколько простым становится код для такого замороченного запроса, как INSERT… ON DUPLICATE:
$data = array('offers_in' => $in, 'offers_out' => $out);
$sql  = "INSERT INTO stats SET pid=?i,dt=CURDATE(),?u ON DUPLICATE KEY UPDATE ?u";
$db->query($sql,$pid,$data,$data); 

В данный момент классом поддерживается 6 типов плейсхолдеров
  • ?s («string») — строки (а также DATE, FLOAT и DECIMAL).
  • ?i («integer») — целые числа.
  • ?n («name») — имена полей и таблиц
  • ?p («parsed») — для вставки уже обработанных частей запроса
  • ?a («array») — набор значений для IN (строка вида 'a','b','c')
  • ?u («update») — набор значений для SET (строка вида `field`='value',`field`='value')

Что вполне достаточно для моих задач, но этот набор всегда можно расширить любыми другими плейсхолдерами, например, для дробных чисел. Делать отдельный плейсхолдер для NULL я не вижу смысла — его можно всегда вписать прямо в запрос.
Автоматическую трансляцию PHP-шного NULL в SQL-евский NULL я решил не делать. Возможно, это чуть усложнит код (в тех редких случаях, когда это нужно), но зато уменьшит его неоднозначность.

Кстати, как многие могли заметить, этот класс во многом напоминает библиотеку DbSimple Дмитрия Котерова. Но у меня есть принципиальные расхождения с некоторыми идеями, заложенными в неё.
Во-первых, я противник любой магии, когда одна и та же функция может возвращать различный результат в зависимости от типа переданных данных. Это, возможно, чуть упрощает написание, но при этом чудовищно затрудняет сопровождение и отладку кода. Поэтому в моем классе вся магия сведена к минимуму, а все операции и типы данных всегда прописываются явно.
Во-вторых, в DbSimple немного, на мой взгляд, переусложнённый синтаксис. С одной стороны, фигурные скобки — гениальная идея. С другой — а зачем, если в нашем распоряжении вся мощь PHP? Поэтому я решил пойти другим путём и весто «внутренней» — заведомо ограниченной — логики ввёл «внешнюю», ограниченную лишь синтаксисом РНР. Главное, чтобы любые динамические элементы попадали в запрос только через плейсхолдеры, а остальное зависит лишь от фантазии разработчика (и функции parse()).

Код класса доступен на Гитхабе, github.com/colshrapnel/safemysql/blob/master/safemysql.class.php
Cheat sheet с основными командами и примерами: phpfaq.ru/misc/safemysql_cheatsheet_ru.pdf
Хорошее представление о возможностях можно получить на странице примеров документации (к сожалению, ещё не законченной), phpfaq.ru/safemysql
Там же есть ответы на часто задаваемые вопросы, такие как «почему ты не используешь родные prepared statements?» и пр.
Тем не менее, буду рад ответить на любые вопросы в комментариях, а так же улучшить по вашим замечаниям как сам класс, так и эту статью.
Tags:
Hubs:
+10
Comments 103
Comments Comments 103

Articles