SQL-инъекции#

SQL-инъекция (sqli) — это уязвимость безопасности, которая позволяет злоумышленнику вмешиваться в запросы к базе данных приложения, внедряя злонамеренный SQL-код через входные данные, меняя исходный SQL-запрос.
Проблема возникает, когда приложение «слепо» объединяет пользовательские данные с кодом SQL-запроса, не проверяя и не обрабатывая их.

Пример инъекции#

Исходный запрос:

SELECT * FROM users WHERE username = '[user_input_1]' AND password = '[user_input_2]'

Злоумышленник вводит в поле username специальную строку: OR 1=1 --. Поле password он может оставить пустым или заполнить чем угодно.

Злонамеренный ввод:

user_input_1: **admin' or 1=1 --**  
user_input_2: **[любой]** 

Результат:

SELECT * FROM users WHERE username = 'admin' or 1=1 --' AND password = 'text'

Чем опасны SQL-инъекции?#

Злоумышленник может:

  1. Обойти аутентификацию.

  2. Получить несанкционированный доступ к данным: читать, копировать, скачивать всю базу данных.

  3. Изменять и удалять данные: удалять, изменять, добавлять записи.

  4. Выполнять команды на сервере БД: в некоторых случаях атака может привести к полному захвату сервера.

Методы защиты#

  1. Подготовленные выражения (Prepared Statement) - наиболее эффективный метод защиты. Это механизм выполнения SQL-запросов, при котором шаблон SQL-запроса и данные передаются отдельно. Это полностью исключает возможность SQL-инъекций. Предпочтительнее использовать именно этот метод защиты.

val svName = "admin"
val svPass = "qwerty1234"
SQL(s"SELECT * FROM users WHERE username = {sUserName} AND password = {sPassHash}")
    .on("sUserName" -> svName, "sPassHash" -> svPass)
  1. Использование функций экранирования. Если использовать подготовленные выражения не представляется возможным, необходимо воспользоваться функциями экранирования, они «обезвреживают» специальные символы:

  • escapeString(input: String, quoteValue: Boolean): String - для экранирования строки. Экранирует символы одинарной кавычки (“) в строке. Если quoteValue = true, заключит значение в одинарные кавычки.

    val sUserInput = SqlEscape.escapeString("' union select id from Btk_User --", false)
    SQL(s"SELECT id FROM Bs_Goods WHERE sName = '$sUserInput' ")
  • escapeNumber(input: String): String - для экранирования числа. Удаляет спецсимволы („;“, „–“, „/“, „/“, пробелы).

    val sIdGood = SqlEscape.escapeNumber("1 OR 1=1 --")
    SQL(s"""SELECT id FROM Bs_Goods WHERE id = $sIdGood""")
  • escapeName(input: String): String - для экранирования имен сущностей, например имена таблиц и колонок. Экранирует одинарные кавычки („), удаляет спецсимволы ';', '--', '/*', '*/', '(', ')'.

  • escapeDate(input: String): String - для экранирования даты. Экранирует символы одинарной кавычки („).

  • escapeJson(input: String): String - для экранирования json. Экранирует символы одинарной кавычки („).

Динамическое построение SQL#

Динамическое построение SQL реализовано через SQLBuilder — инструмент, который собирает текст SQL-запроса из отдельных частей. Вместо склеивания строк вручную разработчик последовательно указывает, что запрос должен выбрать, из какой таблицы получить данные и по какому условию их отобрать. В результате билдер возвращает обычную строку SQL; к БД он не подключается и запрос не выполняет.

Билдер нужен, когда меняется именно структура запроса: например, программная логика выбирает разрешенный набор колонок, таблицу или дополнительные условия. Если запрос всегда имеет одну и ту же структуру, достаточно обычного параметризованного SQL-запроса без билдера.

Явное правило применения: если программа выбирает таблицу или набор колонок из заранее разрешенных вариантов, используйте SQLBuilder. Например, имя таблицы передавайте в FROM через escape_next, как в примере ниже. Если меняются только значения параметров, передавайте их через .on(...) без билдера. Не передавайте в методы билдера произвольный SQL-фрагмент из пользовательского ввода: SQLBuilder не делает такой фрагмент безопасным.

Если sTableName подставить в SQL-строку напрямую, злоумышленник сможет передать вместо имени таблицы свой SQL-фрагмент и изменить запрос. Вызов escape_next.FROM(...) обрабатывает динамический фрагмент как имя таблицы, но sTableName всё равно должен выбираться из разрешенного набора.

Пример формирования запроса для получения класса по идентификатору. Условие id = {idClass} остается шаблоном, а значение idClass передается отдельно при выполнении запроса.

val sSql = SQLBuilder
  .SELECT("id", "sName")
  .FROM("Btk_Class")
  .WHERE("id = {idClass}")
  .toString

SQL(sSql)
  .on("idClass" -> idClass)

После вызова toString переменная sSql содержит следующий текст:

SELECT id
   , sName
FROM Btk_Class
WHERE (id = {idClass})

В данном примере toString возвращает текст запроса, а SQL(...).on(...) выполняет его с параметром. Значение idClass не становится частью SQL-строки, поэтому его нельзя подменить SQL-кодом.

Экранирование динамических фрагментов

Если в программе нужно добавить динамический фрагмент структуры запроса, перед соответствующим методом можно вызвать escape_next. Например, ниже в sTableName передается имя таблицы, которое программа получила из собственного заранее разрешенного набора.

val sSql = SQLBuilder
  .SELECT("id", "sName")
  .escape_next.FROM(sTableName)
  .WHERE("id = {idClass}")
  .toString

escape_next действует только на следующий вызов билдера. В примере он обрабатывает аргумент FROM функцией Gtk_SqlPkg.escapeName, после чего следующий фрагмент добавляется без такой обработки.

escape_next не заменяет параметризацию. Он удаляет отдельные специальные последовательности, но не проверяет SQL-выражение целиком и не передает значение как параметр. Поэтому им можно дополнительно обработать динамический фрагмент, но значения из формы, интеграции и других внешних источников всегда передавайте через параметры в .on(...).