🧱 Суть

SQL Injection – это уязвимость, при которой пользователь может изменить структуру SQL-запроса через входные данные.

Проблема возникает, когда данные пользователя напрямую вставляются в SQL.

Пример:

$id = $_GET['id'];
 
$sql = "
SELECT *
FROM users
WHERE id = $id
";

Запрос:

?id=5

превращается в:

SELECT *
FROM users
WHERE id = 5

Но пользователь может отправить:

?id=5 OR 1=1

И получить:

SELECT *
FROM users
WHERE id = 5 OR 1=1

Теперь условие всегда истинно.

📦 Проблема

1. Склейка SQL-строк опасна

Плохо:

$email = $_POST['email'];
 
$sql = "
SELECT *
FROM users
WHERE email = '$email'
";

Если пользователь отправит:

' OR '1'='1

Получится:

SELECT *
FROM users
WHERE email = '' OR '1'='1'

Запрос вернёт все записи.

2. Экранирование строк – плохое решение

Иногда пытаются делать:

mysqli_real_escape_string();

или вручную заменять символы.

Проблемы:

  • легко ошибиться
  • нужно помнить про каждый тип данных
  • сложнее поддерживать

Правильное решение:

отделять SQL-код от пользовательских данных

3. Любой внешний ввод считается опасным

Источники риска:

  • $_GET
  • $_POST
  • $_COOKIE
  • JSON API
  • файлы
  • HTTP-заголовки
    Нельзя доверять данным только потому, что они пришли от клиента.

✅ Решение

Использовать подготовленные запросы (Prepared Statements).

Идея:

  1. SQL отправляется отдельно
  2. данные передаются отдельно
  3. база сама правильно обрабатывает значения

Схема:

SQL-шаблон
      +
Параметры
      ↓
Безопасный запрос

💻 Код

Prepared Statements в PDO

Вместо:

$sql = "
SELECT *
FROM users
WHERE id = $id
";

Используем:

$stmt = $pdo->prepare(
    "
    SELECT *
    FROM users
    WHERE id = ?
    "
);
 
$stmt->execute([$id]);

Теперь:

$id = "5 OR 1=1";

будет восприниматься как обычное значение:

WHERE id = '5 OR 1=1'

а не как часть SQL-кода.

Позиционные параметры

Самый простой вариант:

$stmt = $pdo->prepare(
    "
    SELECT *
    FROM users
    WHERE id = ?
    "
);
 
$stmt->execute([
    15
]);

? заменяется значением из массива.

Пример нескольких параметров:

$stmt = $pdo->prepare(
    "
    SELECT *
    FROM users
    WHERE email = ?
    AND status = ?
    "
);
 
$stmt->execute([
    $email,
    'active'
]);

Именованные параметры

Более читаемый вариант:

$stmt = $pdo->prepare(
    "
    SELECT *
    FROM users
    WHERE email = :email
    "
);
 
$stmt->execute([
    'email' => $email
]);

Плюсы:

  • легче читать
  • меньше ошибок
  • удобно при большом количестве параметров

INSERT через Prepared Statement

Опасно:

$sql = "
INSERT INTO users(name)
VALUES ('$name')
";

Безопасно:

$stmt = $pdo->prepare(
    "
    INSERT INTO users(name)
    VALUES (:name)
    "
);
 
$stmt->execute([
    'name' => $name
]);

UPDATE через Prepared Statement

$stmt = $pdo->prepare(
    "
    UPDATE users
    SET name = :name
    WHERE id = :id
    "
);
 
$stmt->execute([
    'name' => $name,
    'id' => $id
]);

DELETE через Prepared Statement

$stmt = $pdo->prepare(
    "
    DELETE FROM users
    WHERE id = :id
    "
);
 
$stmt->execute([
    'id' => $id
]);

Почему Prepared Statements защищают от SQL Injection

Обычный запрос:

SQL + данные
        ↓
одна строка
        ↓
База выполняет код

Проблема:

данные могут стать кодом

Prepared Statement:

SQL
 ↓
подготовка

данные
 ↓
параметры

объединение внутри БД

Теперь:

данные остаются данными

Типы параметров

Можно указать тип вручную:

$stmt->bindValue(
    ':id',
    $id,
    PDO::PARAM_INT
);

Пример:

$stmt = $pdo->prepare(
    "
    SELECT *
    FROM users
    WHERE id = :id
    "
);
 
$stmt->bindValue(
    ':id',
    10,
    PDO::PARAM_INT
);
 
$stmt->execute();

Основные типы:

ТипКонстанта
строкаPDO::PARAM_STR
числоPDO::PARAM_INT
booleanPDO::PARAM_BOOL
NULLPDO::PARAM_NULL

Что Prepared Statements НЕ защищают

Подготовленные запросы защищают значения, но не структуру SQL.

Например:

$order = $_GET['sort'];
 
$sql = "
SELECT *
FROM users
ORDER BY $order
";

Так делать нельзя.

Параметры нельзя использовать для:

  • имён таблиц
  • имён колонок
  • SQL-ключевых слов

Плохо:

ORDER BY ?

Правильно:

Использовать whitelist:

$allowed = [
    'name',
    'created_at'
];
 
if (
    in_array($sort, $allowed)
) {
    $sql = "
    SELECT *
    FROM users
    ORDER BY $sort
    ";
}

Prepared Statements и ORM

Современные ORM обычно используют тот же принцип.

Например:

User::where(
    'id',
    $id
)->first();

Внутри ORM создаёт:

WHERE id = ?

и передаёт параметр отдельно.

Дополнительная защита

Prepared Statements – основа, но нужны ещё:

Валидация данных

Проверять:

  • формат
  • длину
  • диапазон

Пример:

if (!is_numeric($id)) {
    exit();
}

Минимальные права пользователя БД

Не стоит подключаться к БД под:

root

Лучше:

app_user

с ограниченными правами.

Обработка ошибок

Не показывать пользователю:

echo $exception->getMessage();

Потому что там может быть:

SQL syntax error
Table users doesn't exist

Лучше:

log_error($exception);
 
echo "Ошибка сервера";

Частые ошибки

Склеивать SQL строки

Плохо:

$sql =
"SELECT * FROM users WHERE id="
. $id;

Использовать подготовленные запросы только иногда

Плохо:

SELECT * FROM users WHERE id=?

но:

DELETE FROM users WHERE id=$id

Считать, что проверка клиента достаточна

Плохо:

<input maxlength="20">

Это можно обойти.

Проверка должна быть на сервере.

Хранить пароли напрямую

Плохо:

INSERT password='123456'

Лучше:

password_hash(
    $password,
    PASSWORD_DEFAULT
);

🎯 Вывод

  • SQL Injection возникает, когда данные пользователя попадают прямо в SQL-код
  • нельзя собирать SQL через конкатенацию строк
  • Prepared Statements отделяют SQL от данных
  • в PDO используются prepare() и execute()
  • параметры можно передавать через ? или именованные :name
  • подготовленные запросы защищают значения, но не имена таблиц и колонок
  • дополнительно нужна валидация входных данных
  • безопасность должна быть на стороне сервера

🔗 Связано