После написания статьи о защите от инъекций Я приступил к написанию класса, который реализовал бы изложенные в нем идеи.
Точнее, поскольку ключевой функционал уже использовался в рамках рабочего фреймворка, я начал выделять его в отдельный класс.
Я хотел бы воспользоваться этой возможностью, чтобы поблагодарить членов PHPClub за помощь в исправлении нескольких критических ошибок и полезные комментарии.
Ниже я постараюсь описать основные возможности, но сначала немного отказ от ответственности Есть несколько способов работы с SQL — можно использовать построитель запросов, можно использовать ORM, можно работать с чистым SQL. Я выбрал последний вариант, потому что он мне ближе.
Я не думаю, что первые два совсем плохи.
Просто лично мне всегда было тесно в их рамках.
Но я ни в коем случае не утверждаю, что моя версия лучше.
Это просто еще один вариант. Что можно использовать, в том числе, при написании ORM. В любом случае я считаю, что безопасный способ работы с чистым SQL не может принести никакого вреда.
Но в то же время это может помочь последним оставшимся приверженцам использования mysql_* в коде приложения наконец отказаться от этой плохой практики.
В двух словах, класс построен на основе набора вспомогательных функций, которые позволяют выполнять большинство операций с базой данных в одну строку, обеспечивая при этом (в отличие от стандартных API) полный защита от SQL-инъекций, реализованная с помощью расширенного набора заполнителей, защищающих любые типы данных, которые могут быть подвергнуты запросу.
Класс основан на трех основных принципах:
- 100% защита от SQL-инъекций
- При этом защитой очень удобно пользоваться, делая код короче, а не длиннее.
- Универсальность, мобильность и простота обучения
Безопасность
обеспечивается теми самыми двумя правилами, которые я сформулировал в статье:- Любой - без исключений! — в запрос включены динамические элементы только через заполнители.
- Все, что нельзя заменить через плейсхолдеры, сначала прогоняется через белый список.
Поэтому, чтобы сделать защиту полной, нам пришлось отказаться от заведомо ограниченного понятия подготовленных высказываний в пользу более широкого понятия — заполнителей.
Более того, типизированные заполнители (все мы знаем эту штуку из семейства функций printf(): %d — это заполнитель, который сообщает парсеру, как обрабатывать подставленное значение, в данном случае как целое число).
Нововведение оказалось настолько удачным, что сразу решило множество проблем и значительно упростило код. Подробнее о типизированных заполнителях я напишу ниже.
Поддержка фильтрации по белым спискам обеспечивается двумя функциями, несколько надуманными, но тем не менее необходимыми.
Удобство и краткость кода приложения
Здесь мне тоже очень помогли типизированные плейсхолдеры, которые позволили сделать вызовы функций однострочными, передавая и запрос, и данные для него сразу.Плюс набор помощников, напоминающий те, что есть в PEAR::DB — функции, которые сразу возвращают результат нужного типа.
Все хелперы организованы по одной схеме: в функцию передается один обязательный параметр — запрос с плейсхолдерами и столько необязательных параметров, сколько необходимо, количество и порядок которых должны совпадать с количеством и порядком плейсхолдеров в запросе.
.
Функции семейства Ind используют еще один обязательный параметр — имя поля, по которому индексируется возвращаемый массив.
Опираясь на свой опыт, я пришел к следующему набору возвращаемых значений (и, как следствие, помощников):
- query() — возвращает ресурс mysqli. Может использоваться традиционно, с fetch() и т. д.
- getOne() — возвращает скаляр, первый элемент первой строки результата
- getRow() — одномерный массив, первая строка результата
- getCol() — одномерный массив скаляров — столбец таблицы
- getAll() — двумерный массив, индексированный числами по порядку
- getInd() — двумерный массив, индексированный по значениям поля, заданного первым параметром
- getIndCol() — это массив скаляров, индексированных по полю из первого параметра.
Незаменим для составления словарей типа ключ => значение
Этот код содержит только необходимые и значимые элементы, но в нем нет ничего лишнего и повторяющегося.$data = $db->getAll("SELECT * FROM Эn WHERE mod=Эs LIMIT Эi",$table,$mod,$limit);
Все потроха аккуратно спрятаны внутри класса: хелпер getAll() позволяет сразу получить желаемый результат без написания циклов в коде приложения, а типизированные плейсхолдеры позволяют безопасно добавить динамические элементы в запрос любой типы без указания привязок вручную (bind_param).
Дополнительный СУХОЙ код! В случаях использования заполнителей Эa и Эu разница в объёме кода становится ещё больше: $data = $db->getAll("SELECT * FROM table WHERE category IN (Эa)",$ids);
Универсальность и простота обучения
стоять на трёх китах:- Очень небольшой API — полдюжины заполнителей и столько же помощников.
- Мы работаем со старым добрым SQL, который не нужно переучивать.
- Казалось бы, незаметная, но невероятно полезная функция 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, с другой — делаем его безопасным, а с третьей — радикально сокращаем объём кода.
Подробнее о типизированных заполнителях
Для начала давайте ответим на вопрос, зачем вообще заполнители? Это, в общем-то, уже обычное место, но, тем не менее, повторюсь - любые динамические данные следует включать в запрос только через заполнители следующие причины:- самое главное это безопасность.
Добавляя переменную через заполнитель, мы можем быть уверены, что она будет отформатирована правильно.
- локальное форматирование.
Это не менее важный момент. Во-первых, данные форматируются непосредственно перед вводом запроса и не затрагивают исходную переменную, которую потом можно использовать где-то еще.
Во-вторых, данные форматируются именно там, где они нужны, а не до запуска скрипта, как в магических кавычках, а не в десяти возможных местах кода несколькими разработчиками, каждый из которых может положиться на другого.
Но почему? Здесь хотелось бы на минутку остановиться и проследить историю развития программистской мысли в области защиты от инъекций.
Сначала был хаос - никакой защиты вообще, мы распихали всё как было.
Дальше не особо лучше, с парадигмой «давайте восстановим от пользователя всё, что пришло в скрипт» и кульминацией в виде магических кавычек.
Тогда лучшие умы пришли к выводу, что правильно говорить не об экранировании, а о форматировании.
Потому что форматирование не всегда сводится к одному макету.
Так в PDO появился метод quote(), который делал полное форматирование строки — не только экранировал в ней специальные символы, но и заключал ее в кавычки, не полагаясь на программиста.
В результате, даже если программист использовал эту функцию не там, где надо (например, для числа), инъекция всё равно не сработала (а в случае голого экранирования через mysql_real_escape_string она легко проходит, если мы поместим число в запрос, не заключая его в кавычки).
При использовании этой функции для форматирования идентификатора эта функция приводила к ошибке на этапе разработки, что подсказало автору кода, что он немного не прав.
К сожалению, на этом авторы PDO остановились, так как в сознании разработчиков до сих пор прочно сидит идея о том, что в запросе нужно форматировать только строки.
Но на самом деле запрос содержит гораздо больше элементов разного типа.
И для каждого требуется свой тип форматирования! То есть единственный метод quote() нам никак не подойдет — нам нужно много разных кавычек.
И не как исключение «вот quoteName()», а как одно из основных понятий: у каждого типа свой формат. Ну а поскольку видов форматирования много, то тип надо как-то указать.
И лучше всего для этого подходит типизированный заполнитель.
Кроме того, типизированный заполнитель ОЧЕНЬ удобен! Во-первых, потому что специальный оператор привязки значения к заполнителю становится ненужным (но указать тип передаваемого значения все равно можно!) Во-вторых, поскольку мы изобрели типизированный заполнитель, мы можем приклеивать огромное количество этих заполнителей для решения многих рутинных задач по написанию SQL-запросов.
Первым делом создадим заполнитель для идентификаторов — нам его очень не хватает в реальной жизни, а не воображаемой авторами стандартных API. Как только разработчик сталкивается с необходимостью динамического добавления имени поля в запрос, каждый начинает извращаться по-своему, кто в лес, кто в дрова.
Здесь все унифицировано с остальными элементами запроса, и добавить идентификатор не сложнее, чем добавить строку.
Но при этом идентификатор форматируется не как строка, а по своим правилам — он заключен в обратные кавычки, а внутри этих кавычек экранируется удвоением.
Более того.
Следующей головной болью для любого разработчика, который когда-либо пытался использовать стандартные подготовленные операторы в реальной жизни, является оператор 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 («строка») – строки (а также DATE, FLOAT и DECIMAL).
- Эi («целое») — целые числа.
- Эn («имя») – названия полей и таблиц
- Эp («разобранный») — для вставки уже обработанных частей запроса
- Эa («массив») — набор значений для IN (строка типа 'a','b','c')
- Эu («обновление») — набор значений для SET (строка типа `field`='value',`field`='value')
Делать отдельный плейсхолдер для NULL не вижу смысла — его всегда можно включить прямо в запрос.
Я решил не переводить автоматически PHP NULL в SQL NULL. Возможно, это немного усложнит код (в тех редких случаях, когда это необходимо), но уменьшит его неоднозначность.
Кстати, как многие могли заметить, этот класс во многом напоминает библиотеку DbSimple Дмитрия Котерова.
Но у меня есть принципиальные расхождения с некоторыми идеями, содержащимися в нем.
Во-первых, я противник всякой магии, когда одна и та же функция может возвращать разные результаты в зависимости от типа передаваемых данных.
Это может немного облегчить написание, но также сильно усложняет поддержку и отладку кода.
Поэтому в моем классе вся магия сведена к минимуму, а все операции и типы данных всегда написаны явно.
Во-вторых, на мой взгляд, у DbSimple немного усложненный синтаксис.
С одной стороны, фигурные скобки — блестящая идея.
С другой стороны, зачем нам это делать, если в нашем распоряжении вся мощь PHP? Поэтому я решил пойти другим путем и ввел «внешнюю» логику, ограниченную только синтаксисом PHP, вместо «внутренней» — заведомо ограниченной — логики.
Главное, что любые динамические элементы попадают в запрос только через плейсхолдеры, а остальное зависит только от фантазии разработчика (и функции parse()).
Код класса доступен на Github. github.com/colshrapnel/safemysql/blob/master/safemysql.class.php Шпаргалка с основными командами и примерами: phpfaq.ru/misc/safemysql_cheatsheet_ru.pdf Хорошее представление о возможностях можно получить на странице примеров документации (к сожалению, еще не законченной), phpfaq.ru/safemysql Также есть ответы на часто задаваемые вопросы, например «почему бы вам не использовать нативные подготовленные операторыЭ» и т. д. Однако я буду рад ответить на любые вопросы в комментариях, а также улучшить как сам класс, так и эту статью на основе ваших комментариев.
Теги: #sql-инъекция #placeholder #подготовленные операторы #mysqli #информационная безопасность #php #MySQL
-
Признаки Хорошей Службы Перевода
19 Oct, 24 -
Интерфейс Twitter В Форме Excel
19 Oct, 24