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. Никогда не суйте сырой пользовательский идентификатор через форматирование строк.