9 мин. чтения

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

Простым языком объясняем что такое SQL-инъекции, почему они опасны и как от них защититься. С примерами уязвимого и безопасного кода на Node.js.

Что вообще такое SQL-инъекция?

Представьте, что у вас есть сайт с формой входа. Пользователь вводит логин и пароль, а ваш сервер проверяет их в базе данных. Всё просто, да? А теперь представьте, что злоумышленник вместо логина вводит специальный текст, который меняет логику вашего запроса к базе данных. Это и есть SQL-инъекция.

Звучит как магия? Сейчас разберёмся, как это работает, и - самое главное - как от этого защититься. Спойлер: это проще, чем кажется.

Как это работает (на пальцах)

SQL - это язык, на котором мы "разговариваем" с базой данных. Когда пользователь вводит логин на сайте, сервер отправляет в базу запрос примерно такого вида:

-- Это SQL-запрос. Он говорит базе данных:
-- "Найди мне пользователя с таким логином и паролем"
SELECT * FROM users WHERE username = 'ivan' AND password = '12345'

Если пользователь с таким логином и паролем существует - база вернёт его данные. Если нет - вернёт пустой результат. Логично? Абсолютно.

А теперь смотрите, что произойдёт, если злоумышленник вместо логина введёт вот это:

' OR 1=1 --

Наш запрос превратится в:

-- Вот что получилось:
SELECT * FROM users WHERE username = '' OR 1=1 --' AND password = 'неважно'

-- Разберём по частям:
-- username = ''  - пустой логин (ну ладно)
-- OR 1=1          - ИЛИ 1 равно 1 (а это ВСЕГДА правда!)
-- --              - это комментарий, всё после него игнорируется
-- Результат: база возвращает ВСЕХ пользователей!

Условие 1=1 всегда истинно, а -- превращает проверку пароля в комментарий. Итог: злоумышленник получает доступ без пароля, обычно к первому аккаунту в базе (а это часто - администратор).

Как выглядит уязвимый код

Вот типичный пример кода на Node.js, который категорически нельзя использовать в реальных проектах:

// ОПАСНЫЙ КОД! Никогда так не делайте!
// Здесь пользовательский ввод напрямую вставляется в SQL-запрос

app.post('/api/login', async (req, res) => {
  // req.body - это то, что пользователь отправил из формы
  const username = req.body.username;  // Например: "ivan"
  const password = req.body.password;  // Например: "12345"

  // Вот тут ПРОБЛЕМА: мы просто подставляем текст от пользователя
  // прямо в SQL-запрос, как будто это безопасные данные.
  // Спойлер: это НЕ безопасные данные.
  const query = `SELECT * FROM users 
    WHERE username = '${username}' 
    AND password = '${password}'`;
  
  // Если username = "' OR 1=1 --", то запрос будет сломан
  const [rows] = await db.execute(query);
  
  if (rows.length > 0) {
    res.json({ message: 'Добро пожаловать!' });
  } else {
    res.json({ message: 'Неверный логин или пароль' });
  }
});

Проблема здесь в строке с обратными кавычками (шаблонная строка). Мы берём текст от пользователя и просто вклеиваем его в SQL. Это как дать незнакомцу дописать ваше письмо в банк - он может написать там что угодно.

Что может натворить злоумышленник

SQL-инъекция - это не просто "обойти логин". Вот что ещё можно сделать:

Украсть все данные

Допустим, на сайте есть поиск товаров. Злоумышленник может через поиск вытащить данные из совсем другой таблицы:

-- Нормальный запрос (ищем товары в категории "телефоны"):
SELECT name, price FROM products WHERE category = 'телефоны'

-- А вот что отправит злоумышленник в поле поиска:
-- ' UNION SELECT username, password FROM users --
-- 
-- Итоговый запрос:
SELECT name, price FROM products WHERE category = ''
UNION SELECT username, password FROM users --'

-- UNION объединяет результаты двух запросов
-- Вместо списка товаров вернутся логины и пароли!
-- Красиво? Нет. Страшно? Да.

Удалить всю базу

-- Злоумышленник вводит в любое поле:
-- '; DROP TABLE users; --
--
-- Итоговый запрос:
SELECT * FROM products WHERE name = ''; DROP TABLE users; --'

-- DROP TABLE users - удаляет таблицу с пользователями
-- Все данные пропали. Бэкап есть? Надеемся, что да.

Как защититься

Способ 1: Параметризованные запросы (самый надёжный)

Это главный и самый важный способ защиты. Идея простая: мы не вклеиваем данные пользователя в SQL напрямую, а передаём их отдельно. База данных сама разберётся, как их безопасно подставить.

// БЕЗОПАСНЫЙ КОД - используем параметризованные запросы

app.post('/api/login', async (req, res) => {
  const username = req.body.username;
  const password = req.body.password;

  // Знак ? - это "placeholder" (заполнитель)
  // Мы говорим базе: "вот тебе запрос, а вот отдельно - данные"
  // База данных НИКОГДА не выполнит данные как SQL-код
  const [rows] = await pool.execute(
    'SELECT * FROM users WHERE username = ? AND password_hash = ?',
    [username, password]  // Эти значения подставятся вместо знаков ?
  );

  // Даже если username = "' OR 1=1 --", база будет искать
  // пользователя с логином "' OR 1=1 --" (буквально такой текст)
  // и, конечно, не найдёт. Инъекция не сработает!

  if (rows.length > 0) {
    res.json({ message: 'Добро пожаловать!' });
  } else {
    res.json({ message: 'Неверный логин или пароль' });
  }
});

Ещё примеры параметризованных запросов для разных ситуаций:

// Добавление нового пользователя
// Три знака ? - три параметра
const [result] = await pool.execute(
  'INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)',
  [username, email, hashedPassword]
);

// Обновление данных
const [result] = await pool.execute(
  'UPDATE users SET email = ? WHERE id = ?',
  [newEmail, userId]
);

// Поиск с фильтрами
const [products] = await pool.execute(
  'SELECT * FROM products WHERE price BETWEEN ? AND ? AND category = ?',
  [minPrice, maxPrice, category]
);

// Удаление (тоже с параметром!)
const [result] = await pool.execute(
  'DELETE FROM sessions WHERE user_id = ?',
  [userId]
);

Способ 2: ORM-библиотеки (ещё проще)

ORM (Object-Relational Mapping) - это библиотека, которая позволяет работать с базой данных, не написав ни строчки SQL. Она сама генерирует безопасные запросы. Вот примеры:

// Prisma - самый популярный ORM для Node.js
// Вместо SQL пишем обычный JavaScript/TypeScript

// Найти пользователя по email
// Prisma сама сделает безопасный SQL-запрос
const user = await prisma.user.findUnique({
  where: { 
    email: req.body.email  // Безопасно! Prisma сама экранирует
  }
});

// Найти товары с фильтрами
const products = await prisma.product.findMany({
  where: {
    category: req.query.category,     // Безопасно
    price: {
      gte: 100,  // gte = greater than or equal (больше или равно)
      lte: 5000  // lte = less than or equal (меньше или равно)
    },
    name: {
      contains: req.query.search  // Поиск по части названия
    }
  },
  orderBy: { price: 'asc' },  // Сортировка по цене
  take: 20                       // Вернуть только 20 штук
});

// Создать нового пользователя
const newUser = await prisma.user.create({
  data: {
    username: req.body.username,
    email: req.body.email,
    passwordHash: hashedPassword
  }
});

Способ 3: Проверка входных данных (дополнительная страховка)

Даже если вы используете параметризованные запросы, полезно проверять что пользователь отправляет. Это как ремень безопасности плюс подушка безопасности - лишним не будет:

// Используем библиотеку Zod для проверки данных
import { z } from 'zod';

// Описываем правила: какие данные мы ожидаем
const loginSchema = z.object({
  // username должен быть строкой от 3 до 50 символов
  // и содержать только буквы, цифры и подчёркивание
  username: z.string()
    .min(3, 'Логин должен быть минимум 3 символа')
    .max(50, 'Логин слишком длинный')
    .regex(/^[a-zA-Z0-9_]+$/, 'Только буквы, цифры и _'),
  
  // password - строка от 8 до 128 символов
  password: z.string()
    .min(8, 'Пароль должен быть минимум 8 символов')
    .max(128, 'Пароль слишком длинный')
});

app.post('/api/login', async (req, res) => {
  // Проверяем данные перед использованием
  const result = loginSchema.safeParse(req.body);
  
  // Если данные не прошли проверку - возвращаем ошибку
  if (!result.success) {
    return res.status(400).json({ 
      errors: result.error.flatten().fieldErrors 
    });
    // Пример ответа: { errors: { username: ["Только буквы, цифры и _"] } }
  }

  // Теперь данные точно корректные
  const { username, password } = result.data;
  
  // И используем параметризованный запрос (двойная защита!)
  const [rows] = await pool.execute(
    'SELECT * FROM users WHERE username = ?',
    [username]
  );
  // ... дальше проверяем пароль
});

Дополнительная защита на уровне базы данных

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

-- Создаём пользователя, который может только ЧИТАТЬ данные
-- (для публичной части сайта, где данные только показываются)
CREATE USER 'app_reader'@'localhost' IDENTIFIED BY 'надёжный_пароль';

-- Даём ему право только на чтение, и только в конкретных таблицах
GRANT SELECT ON mydb.products TO 'app_reader'@'localhost';
GRANT SELECT ON mydb.categories TO 'app_reader'@'localhost';
-- Этот пользователь НЕ МОЖЕТ удалить или изменить данные!

-- Для админ-панели - отдельный пользователь с правами на запись
CREATE USER 'app_admin'@'localhost' IDENTIFIED BY 'другой_надёжный_пароль';
GRANT SELECT, INSERT, UPDATE ON mydb.* TO 'app_admin'@'localhost';
-- Но даже он НЕ МОЖЕТ удалять таблицы (нет прав на DROP)

Как проверить свой сайт на уязвимости

Есть специальные инструменты, которые автоматически проверяют сайт на SQL-инъекции:

# sqlmap - бесплатный инструмент для тестирования
# Установка: pip install sqlmap

# Проверить GET-запрос (где параметры в URL)
sqlmap -u "http://localhost:3000/api/products?category=test" --batch

# Проверить POST-запрос (где данные в форме)
sqlmap -u "http://localhost:3000/api/login" 
  --data="username=admin&password=test" 
  --batch

# --batch означает "не задавай вопросов, делай всё автоматически"

Важно: используйте такие инструменты только на своих сайтах или с разрешения владельца. Тестирование чужих сайтов без разрешения - это незаконно.

Памятка (повесьте над монитором)

  • Всегда используйте параметризованные запросы (знаки ?) или ORM
  • Никогда не вклеивайте данные от пользователя прямо в SQL-строку
  • Проверяйте входные данные с помощью zod или joi
  • Создавайте отдельных пользователей БД с минимальными правами
  • Не показывайте пользователям технические ошибки базы данных
  • Регулярно обновляйте библиотеки и драйверы
  • Тестируйте свой сайт инструментами вроде sqlmap

Итого

SQL-инъекция - это когда злоумышленник подсовывает вредоносный код через обычное поле ввода на сайте. Защита элементарная: не вклеивайте данные от пользователя в SQL напрямую. Используйте параметризованные запросы (знаки ?) или ORM - и проблема решена раз и навсегда.

Это как закрывать дверь на замок - не гарантирует 100% безопасности, но без этого вы просто приглашаете воров в гости. И да, бэкапы базы данных тоже делайте. На всякий случай. Потому что "всякий случай" имеет привычку случаться в пятницу вечером, когда вы уже открыли пиво.

Все статьи
Защита от SQL-инъекций в Node.js | Enot Software