CodeAuditAgent
Все статьи
  • Безопасность
  • SQL
  • OWASP

Защита от SQL-инъекций в Node.js, Python и Go

Уязвимые и исправленные SQL-запросы для pg, Prisma, psycopg, SQLAlchemy и database/sql в Go, безопасные ORDER BY и списки IN. Соответствует CWE-89.

· Чтение: 7 мин · Lina Source LLC

SQL-инъекция (CWE-89) — одна из старейших ошибок в вебе и по-прежнему одна из самых разрушительных. Причина не изменилась: пользовательский ввод вставляется в текст запроса, и база данных не может отличить данные от кода. Не изменилось и исправление: передавайте запрос и значения по отдельности и позвольте драйверу их связать.

Изменилось другое — место, где живёт ошибка. Большинство команд используют ORM для повседневных запросов, поэтому инъекции теперь появляются в «запасных выходах»: в сыром запросе для отчёта, в поисковом эндпоинте с динамической сортировкой, в скрипте миграции, в админке. В этом руководстве разобраны уязвимая и исправленная версии в трёх экосистемах, а затем два случая, которые одни только параметры не решают.

Ущерб редко ограничивается одной таблицей. Один уязвимый запрос обычно выполняется со всеми привилегиями приложения в базе данных, поэтому через него можно прочитать данные всех клиентов, включая хеши паролей и API-токены, изменить строки, а в некоторых СУБД — добраться до файловой системы или других серверов. Слепые техники извлекают данные по одному биту, даже если результат запроса никогда не показывается пользователю, так что эндпоинт, возвращающий только true или false, всё равно можно эксплуатировать.

Почему параметры работают, а экранирование — нет

В параметризованном запросе драйвер отправляет текст SQL с плейсхолдерами, а значения передаются отдельно, как данные. База данных разбирает команду ещё до того, как увидит значение, поэтому кавычка или точка с запятой во вводе — просто символ в строке. Ручное экранирование пытается сделать значение безопасным для вставки в текст SQL и ломается на кодировках, числовых контекстах, идентификаторах и любых пограничных случаях, о которых вы не подумали. Не экранируйте — связывайте.

Node.js: pg и Prisma

В node-postgres уязвимый шаблон — шаблонная строка, переданная в query. Исправление — плейсхолдер $1 и массив значений. Prisma безопасна по умолчанию, но у неё два API для сырых запросов, и только один из них безопасен при интерполяции.

import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";

const pool = new Pool();
const prisma = new PrismaClient();

// Уязвимо: ввод становится частью текста SQL
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);

// Исправлено: значение связывается как параметр
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);

// Prisma: $queryRaw — тегированный шаблон, интерполяции становятся параметрами
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;

// Prisma: $queryRawUnsafe с конкатенацией уязвим к инъекции
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = '" + email + "'"
);

// Если без $queryRawUnsafe не обойтись, передавайте значения аргументами
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = $1",
  email
);

Тонкая ловушка в Prisma: если сначала собрать строку запроса, а потом передать её в $queryRaw, защита теряется. Безопасность обеспечивает именно синтаксис тегированного шаблона. Если нужно составлять запрос из фрагментов, используйте Prisma.sql и Prisma.join — они сохраняют значения как параметры. То же правило действует для других библиотек с тегированными шаблонами, например postgres.js и slonik: интерполяцию делает безопасной тег, поэтому заранее собранная строка запроса, переданная как обычное значение, обходит защиту.

Python: psycopg и SQLAlchemy

В Python опасные инструменты — f-строки, оператор % и str.format, применённые к SQL. psycopg использует плейсхолдеры %s, которые выглядят как форматирование строк, но им не являются: значения передаются вторым аргументом execute, а не через оператор %. Конструкция text() в SQLAlchemy безопасна с именованными параметрами привязки и небезопасна, если форматировать значения прямо в строку.

Одна деталь psycopg часто сбивает с толку: плейсхолдер — это %s (или %(name)s для именованных значений) независимо от типа столбца, и кавычки вокруг него никогда не ставятся. Если написать '%s' в кавычках, параметр снова превратится в часть строкового литерала и запрос сломается. То же с именованными параметрами в SQLAlchemy: :status, а не ':status'.

from sqlalchemy import text

# psycopg: уязвимо
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")

# psycopg: исправлено, значения передаются отдельно
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))

# SQLAlchemy text(): уязвимо
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))

# SQLAlchemy text(): исправлено с параметром привязки
conn.execute(
    text("SELECT id FROM orders WHERE status = :status"),
    {"status": status},
)

Go: database/sql

database/sql в Go поддерживает плейсхолдеры нативно, но их синтаксис зависит от драйвера: $1 для драйверов PostgreSQL, таких как pgx, и ? для MySQL и SQLite. Уязвимый шаблон — fmt.Sprintf или конкатенация строк для построения условия WHERE. Поскольку Go статически типизирован, возникает соблазн считать, что параметр типа int не может быть опасным. Для самого значения это так, но привычка собирать запросы через Sprintf распространяется на соседние строковые параметры, поэтому используйте плейсхолдеры во всех запросах.

// Уязвимо: конкатенация строк
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)

// Исправлено: плейсхолдер и аргумент (синтаксис PostgreSQL; в MySQL — ?)
var u User
err = db.QueryRowContext(ctx,
    "SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
    // не найдено
}

«Запасные выходы» ORM

ORM защищает конструктор запросов, но не каждый свой метод. Вот где стоит искать в первую очередь в любой кодовой базе:

  • Prisma: $queryRawUnsafe и $executeRawUnsafe, а также любой вызов $queryRaw, получающий готовую строку вместо тегированного шаблона.
  • Sequelize и TypeORM: sequelize.query, вызовы .where() в конструкторе запросов со склеенной строкой и сырые выражения order или group.
  • Knex: knex.raw и whereRaw с интерполированными значениями вместо привязок ?.
  • SQLAlchemy: text() с f-строками, а также literal_column() или имена столбцов, взятые из ввода.
  • Django: .raw(), .extra() и cursor.execute с отформатированными строками.
  • GORM: Where, Order и Raw, вызванные с результатом fmt.Sprintf вместо аргументов.

Динамический ORDER BY: параметры не помогут

Плейсхолдеры связывают значения, а не идентификаторы. Нельзя написать ORDER BY $1 и передать имя столбца: база данных отсортирует по константе. Поэтому сортируемая таблица подталкивает разработчиков обратно к сборке строк — и туда же возвращается инъекция. То же ограничение касается имён таблиц, имён схем и ключевых слов SQL, таких как ASC и DESC.

Безопасный шаблон — список разрешённых значений (allowlist), который сопоставляет ключи сортировки из интерфейса с известными именами столбцов. Ввод выбирает запись в списке, но никогда не становится SQL. С направлением сортировки поступают так же: сопоставляют его с фиксированными ASC или DESC.

var sortColumns = map[string]string{
    "created": "created_at",
    "name":    "name",
    "price":   "price_cents",
}

col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
    col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
    dir = "DESC"
}

// Безопасно: col и dir могут принимать только значения из кода выше
query := fmt.Sprintf(
    "SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
    col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)

Если вам действительно нужен динамический идентификатор, который нельзя внести в allowlist, используйте экранирование идентификаторов из драйвера, а не собственное. В psycopg это sql.SQL(...).format(sql.Identifier(name)); в pgx — pgx.Identifier{name}.Sanitize(). Allowlist всё равно лучше: экранирование делает имя безопасным, но не обязательно допустимым или разрешённым. Экранированный идентификатор по-прежнему позволяет сортировать или фильтровать по любому столбцу таблицы, включая те, которые вы не собирались открывать, например хеш пароля или внутренний рейтинг. Сортировка по скрытому столбцу может раскрыть его значения через порядок результатов — по одному сравнению за раз.

Списки IN без сборки строк

Фильтрация по списку ID — ещё один частый повод для конкатенации. В каждом крупном стеке есть безопасный способ это сделать:

  • PostgreSQL с pg или pgx: передайте весь список одним параметром-массивом и напишите WHERE id = ANY($1). Драйвер отправит его как массив Postgres.
  • Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` разворачивается в отдельный плейсхолдер для каждого элемента.
  • SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), затем передайте список.
  • Go с MySQL: сгенерируйте строку плейсхолдеров по длине списка (strings.Repeat("?,", n) с обрезкой) и передайте значения аргументами. Генерируются только плейсхолдеры, но никогда не значения.
  • Отклоняйте пустые списки до выполнения запроса и ограничивайте размер списка, чтобы через эндпоинт нельзя было строить огромные запросы.

Эшелонированная защита

Параметризация — это исправление. Следующие меры уменьшают ущерб, если какой-то запрос всё же проскочит:

  • Подключайтесь под ролью базы данных, у которой есть только нужные приложению привилегии. Веб-приложению редко нужны DROP, ALTER или доступ к другим схемам.
  • Не возвращайте клиентам сырые ошибки базы данных. Инъекции на основе ошибок строятся именно на них.
  • Проверяйте типы на границе: ID, который должен быть целым числом или UUID, нужно разобрать как таковой ещё до слоя данных.
  • Задайте statement timeout: он ограничивает и слепые инъекции на основе времени, и запросы, вышедшие из-под контроля.

Как найти уже существующие

Начните с поиска перечисленных выше «запасных выходов», затем ищите ключевые слова SQL рядом с форматированием строк: SELECT или WHERE внутри шаблонных строк, f-строк, Sprintf или конкатенации через +. Каждое совпадение либо корректно связано, либо построено по allowlist, либо является ошибкой. Не пропускайте пути кода, которые выглядят внутренними: импортёры CSV, cron-задачи и админ-панели часто получают данные, которые изначально пришли от пользователя, просто на шаг раньше. Значения, прочитанные из вашей же базы данных, могут нести полезную нагрузку, которая ранее была безопасно сохранена, а позже вставлена в запрос, — это называется инъекцией второго порядка. CodeAuditAgent выполняет такую трассировку между файлами для публичных репозиториев GitHub и вставленного кода и сообщает о находках CWE-89 с цитатой запроса, описанием того, как до него доходит ввод, и исправленной версией.

Главное правило короткое: значения передаются в параметрах, идентификаторы берутся из allowlist, и ничто из запроса никогда не вставляется в текст SQL.