Menu

SQL-инъекции в SQLite: защита через параметры

Почему конкатенация строк — это дыра в безопасности, как работают SQL-инъекции на практике и как параметризованные запросы в SQLite ставят на них крест.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

SQL-инъекция — это баг конкатенации строк

SQL-инъекция возникает тогда, когда пользовательский ввод попадает прямо в текст SQL-запроса, который разбирает база данных. Как только эта граница стирается — как только введённое пользователем значение превращается в синтаксис, который СУБД исполняет, — пользователь может делать всё то же, что и вы.

Вот классический антипаттерн в виде псевдокода, который легко воспроизвести на любом языке:

-- ТАК ДЕЛАТЬ НЕЛЬЗЯ
query = "SELECT * FROM users WHERE name = '" + user_input + "'"

Если в user_input лежит Ada, запрос отработает как обычный поиск. А вот если туда попадёт ' OR 1=1 --, получится следующее:

SELECT * FROM users WHERE name = '' OR 1=1 --'

-- закомментирует закрывающую кавычку, OR 1=1 сматчит все строки — и атакующий только что слил вашу таблицу пользователей. В более жёстких вариантах через ; цепляется второй запрос: дропают таблицы, вытаскивают данные или добавляют нового админа.

Уязвимость тут не в SQLite. Она в коде, который склеил эту строку.

Параметризованные запросы — реальная защита от SQL-инъекций

Параметризованный запрос отделяет текст SQL от значений. В SQL стоят плейсхолдеры — ? или :name, — а сами значения вы передаёте отдельным аргументом. SQLite один раз парсит и компилирует SQL, а потом подставляет ваши значения уже в готовый план выполнения. Значения физически не могут превратиться в SQL.

Вот как сделать тот же на вид уязвимый поиск, но безопасно:

В оболочке SQLite вы просто вводите значение руками, а вот в коде приложения это выглядит примерно так (драйвер sqlite3 для Python):

# Python — параметризованный, безопасный
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

Передавайте SQL и кортеж значений двумя отдельными аргументами. Драйвер отправляет их в SQLite по отдельности. Даже если user_input равен ' OR 1=1 --, SQLite будет искать пользователя, которого буквально зовут ' OR 1=1 --, и, разумеется, никого не найдёт.

Что на самом деле означает «безопасно»

Защита от SQL-инъекций здесь строится не на поиске подозрительных шаблонов и не на экранировании. Она структурная. SQLite компилирует запрос во внутреннее представление ещё до того, как увидит ваше значение:

-- В скомпилированном запросе слот, а не строка.
SELECT * FROM users WHERE name = ?
                                 ^
                                 слот для параметра

Когда вы привязываете значение, оно попадает в свой слот как типизированные данные — TEXT, INTEGER, BLOB или что-то ещё. SQLite больше не разбирает его как SQL. У атакующего просто нет синтаксиса, через который можно что-то протащить, — парсер уже отработал.

Именно поэтому параметризованный запрос в SQLite надёжен так, как никогда не будет надёжно экранирование. Экранирование пытается вычистить опасные символы из строки. А привязка параметров вообще не даёт этой опасной строке появиться.

Забудьте про форматирование строк

В каждом языке есть соблазнительный шорткат — f-строки в Python, шаблонные литералы в JavaScript, String.format в Java — и каждый из них стреляет вам в ногу, как только дело доходит до SQL.

# НЕЛЬЗЯ — f-строка подставляет значение прямо в текст SQL
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")

# НЕЛЬЗЯ — та же проблема, форматирование через %
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)

# ПРАВИЛЬНО — плейсхолдер + аргумент со значениями
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

Первые два варианта вклеивают пользовательский ввод прямо в строку SQL ещё до того, как драйвер вообще её увидит. К моменту, когда запрос доходит до SQLite, дело уже сделано. Третий же держит SQL и значение на разных дорожках.

Правило тут механическое: как только рука потянулась собирать SQL-строку через +, f-строки, format или шаблонные литералы в том месте, где должно подставиться значение, — остановитесь и поставьте плейсхолдер.

Несколько параметров и именованные плейсхолдеры

В реальных запросах значений обычно больше одного. SQLite поддерживает как позиционные плейсхолдеры ?, так и именованные :name:

В коде приложения это выглядит так:

# Позиционные
cursor.execute(
    "SELECT * FROM orders WHERE customer = ? AND status = ?",
    ("Ada", "paid"),
)

# Именованные — понятнее, когда параметров несколько
cursor.execute(
    "SELECT * FROM orders WHERE total > :min_total AND status = :status",
    {"min_total": 50, "status": "paid"},
)

Именованные параметры лучше масштабируются. Когда значений становится больше трёх-четырёх, ?, ?, ?, ? превращается в игру «угадай, что куда», тогда как :customer, :total, :status, :created_at говорит сам за себя.

Идентификаторы требуют другого подхода

Привязанные параметры работают только со значениями — с тем, что стоит после =, внутри IN (...) или в VALUES (...). С именами таблиц, именами колонок или ключевыми словами SQL вроде ASC/DESC они не работают.

-- Это НЕ работает. Плейсхолдер не может заменять имя столбца.
SELECT * FROM users ORDER BY ? ASC

Если нужен динамический идентификатор — например, чтобы пользователь сам выбирал колонку для сортировки, — сверяйтесь со списком разрешённых значений ещё до того, как соберёте SQL-запрос:

# Подход с использованием белого списка
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}

if sort_column not in ALLOWED_SORT_COLUMNS:
    raise ValueError(f"Недопустимая колонка сортировки: {sort_column}")

query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)

Пользовательская строка сверяется с фиксированным списком заведомо безопасных значений до того, как попадёт в SQL. f-строка здесь допустима только потому, что sort_column физически не может оказаться ничем, кроме одного из трёх захардкоженных имён.

Реальная попытка SQL-инъекции и как её обезвредить

Давайте посмотрим оба варианта бок о бок на враждебном вводе. Создадим небольшую таблицу users:

Уязвимая форма вернула всех пользователей. А параметризованный вариант ищет пользователя, которого буквально зовут ' OR 1=1 --, и не находит никого. Ввод тот же — результат принципиально разный, потому что во втором случае значение так и не стало частью SQL.

Короткий чек-лист

  • Используйте плейсхолдеры ? или :name для каждого значения, которое пришло извне, — пользовательский ввод, тело запроса, переменные окружения, всё, что вы не вписали в код руками.
  • Никогда не собирайте SQL через +, f-строки или format там, где подставляется значение.
  • Если имя таблицы или столбца динамическое, сверяйтесь с заранее заданным белым списком, прежде чем вставить его в запрос.
  • Доверяйте драйверу. Не пишите свою функцию экранирования кавычек. Механизм связывания параметров старше, проверен боем и работает правильно.
  • На код-ревью задавайте команде один вопрос: подставляется ли пользовательский ввод в текст SQL через конкатенацию? Если да — чините.

Доведите эту привычку до автоматизма, и SQL-инъекции перестанут быть классом багов, о котором вообще нужно думать.

Дальше: подключение из приложений

Вы уже видели безопасную форму запроса — плейсхолдер в SQL и значение, переданное рядом. На следующей странице мы разберём, как реально подключить SQLite из прикладного кода на Python, Node.js и ещё нескольких языках: управление соединениями и место параметризованных запросов в типичном потоке обработки запроса.

Часто задаваемые вопросы

Уязвим ли SQLite к SQL-инъекциям?

Да, не меньше любой другой СУБД — если в коде запросы собираются склейкой строк. Лечится это не настройкой SQLite, а тем, как вы передаёте значения из приложения. Используйте параметризованные запросы с плейсхолдерами ? или :name — драйвер всё сделает безопасно.

Почему параметризованные запросы спасают от инъекций?

Когда вы пишете плейсхолдер ?, SQLite сначала разбирает и компилирует SQL, и только потом подставляет ваши значения в готовое скомпилированное выражение. Значения физически не могут стать частью SQL-синтаксиса — они проходят как данные, и точка. Атакующему попросту не из чего «вырываться».

Может, просто экранировать кавычки во вводе?

Нет. Ручное экранирование — это путь к багам: рано или поздно вы упустите какой-нибудь крайний случай (Unicode-кавычки, хитрые кодировки, маркеры комментариев) и выкатите уязвимость в продакшен. Драйверы дают ? и :name именно для того, чтобы вы вообще не думали про экранирование. Используйте их всегда — даже для значений, которые «точно безопасные».

А если имя таблицы или колонки приходит от пользователя?

Параметры биндятся только к значениям, идентификаторы через них не передать. Если имя таблицы или колонки действительно должно быть динамическим — сверяйте его с белым списком допустимых имён, и только потом подставляйте в SQL. Никогда не суйте сырой пользовательский идентификатор через форматирование строк.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ