<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
БЕЗПЕКА · MIDDLE ЧАСТО ПИТАЮТЬ

Де Eloquent і Query Builder досі пропускають SQL-інʼєкцію?

Query Builder передає через плейсхолдери лише значення: аргументи `where()`, `whereIn()`, `insert()`, `update()`. Усе, що ви пишете рядком у `DB::raw()`, `whereRaw()`, `selectRaw()`, `orderByRaw()`, `havingRaw()`, `DB::select()` чи `DB::statement()`, іде в SQL як є, тому дані туди передають лише масивом біндінгів. Імена колонок і напрям сортування не біндяться взагалі, для них потрібен перелік дозволених значень.

Що не так з цим рядком: `Vacancy::whereRaw("salary_from >= $min")->get()`?
У нас усе на Eloquent, а не на голому PDO. Звідки тут взагалі може взятися SQL-інʼєкція?
Фронтенд передає `?sort=created_at&dir=desc`. Як зробити сортування, щоб не відкрити дірку?
Чим `DB::raw()` небезпечніший за звичайний `where()`, якщо обидва потрапляють в один запит?
SQL injection Eloquent Query Builder DB::raw whereRaw orderBy

Query Builder і Eloquent будують SQL із двох частин: текст запиту з плейсхолдерами ? і масив біндінгів, які PDO передає окремо. Значення з where('email', $email), whereIn(), insert() чи update() потрапляють саме в біндінги, тому ' OR 1=1 -- у полі email так і лишиться дивною адресою. Захист працює рівно доти, доки текст запиту пише фреймворк. Щойно розробник сам формує шматок SQL рядком, Laravel вставляє його як є: це стосується DB::raw(), усіх *Raw()-методів (whereRaw, selectRaw, orderByRaw, havingRaw, groupByRaw), а також DB::select(), DB::statement() і DB::unprepared().

Raw-методи не забороняють, вони потрібні для функцій бази, JSON-операторів і звітів. Правило для них одне: у рядку лише SQL і плейсхолдери, дані другим аргументом. whereRaw('lower(email) = ?', [$email]) безпечний, whereRaw("lower(email) = '$email'") ні, і різницю видно одразу: інтерполяція або крапка-конкатенація всередині raw-виклику є головним маркером на код-ревʼю. Те саме з DB::select(): метод справді готує запит через prepare(), але якщо $id уже вклеєний у рядок, prepared statement отримує готову інʼєкцію.

Окремий клас проблем створюють ідентифікатори. Плейсхолдер може замінити лише значення, тому імʼя колонки, таблиці чи напрям сортування в біндінг не передати: orderByRaw('? desc', [$column]) дасть сортування за рядковою константою, тобто ніякого. orderBy() у Laravel 13 перевіряє напрям і кидає InvalidArgumentException на будь-що, крім asc, desc або SortDirection, а колонку бере як є і загортає в лапки-ідентифікатори. На ці лапки не варто спиратись: документація прямо каже не передавати ввід користувача в імена колонок, і навіть без класичної інʼєкції людина отримує змогу сортувати за password чи remember_token, а за порядком рядків іноді вдається вгадати значення. Правильний шлях один: мапа дозволених ключів на справжні колонки і значення за замовчуванням.

Ще дві речі відрізняють сильну відповідь. Перша: second-order injection, коли значення збережено безпечно, а потім фонова задача читає його з бази і вклеює в raw-запит; у момент склеювання дані виглядають «своїми», і саме тут їх пропускають. Друга: LIKE-шаблони. where('title', 'like', "%$q%") від інʼєкції захищений, але % і _ у пошуковому рядку працюють як спецсимволи, тому для точного пошуку їх екранують, памʼятаючи, що SQLite потребує явного ESCAPE. Базовий механізм prepared statements, емуляцію в PDO MySQL і bindValue() проти bindParam() розібрано в картці про PDO.

// Було: значення вклеєне в рядок SQL, Eloquent тут нічим не допоможе.
// ?min=0 OR 1=1 перетворює фільтр на «всі рядки».
$vacancies = Vacancy::whereRaw("salary_from >= {$request->input('min')}")->get();

// Стало: SQL окремо, значення окремо.
$vacancies = Vacancy::whereRaw('salary_from >= ?', [$request->integer('min')])->get();

// Ще краще без raw: where() сам передає значення біндінгом.
$vacancies = Vacancy::where('salary_from', '>=', $request->integer('min'))->get();

// Імʼя колонки не біндиться, тому лише перелік дозволених.
$sortable = ['date' => 'published_at', 'salary' => 'salary_from'];
$request->validate([
    'sort' => ['nullable', Rule::in(array_keys($sortable))], // інакше 422
    'dir' => ['nullable', 'in:asc,desc'],
    'q' => ['nullable', 'string', 'max:100'],
]);

Vacancy::query()
    ->where('title', 'like', '%'.$request->query('q').'%')    // значення: біндінг
    ->orderBy($sortable[$request->query('sort') ?? 'date'],   // колонка: лише з мапи
        $request->query('dir') === 'asc' ? 'asc' : 'desc')    // напрям: два варіанти
    ->paginate(20);
Що захист Query Builder тримається на окремих біндінгах для значень і закінчується там, де розробник сам пише шматок SQL рядком.
Що всі `*Raw()`-методи і `DB::select()` приймають біндінги другим аргументом (`whereRaw('lower(email) = ?', [$email])`), і саме так їх і пишуть.
Що імʼя колонки, таблиці чи напрям сортування плейсхолдером не передати, тож рішення тут одне: мапа дозволених значень, а не екранування.
Що `orderBy()` у Laravel сам відкидає напрям, відмінний від `asc`/`desc` (`InvalidArgumentException`), а колонку бере як є, і документація прямо забороняє віддавати туди ввід користувача.
Що інʼєкцію на код-ревʼю шукають за маркерами: інтерполяція `"...$var..."` або конкатенація всередині raw-методів, `DB::select()`, `DB::statement()`, `DB::unprepared()`.
Відповісти «у нас Eloquent, тому інʼєкцій немає»: `whereRaw("salary_from >= $min")` склеює рядок ще до того, як Eloquent його побачить.
Лікувати `addslashes()` або `DB::getPdo()->quote()` замість біндінгу: для числа без лапок і для імені колонки це не працює зовсім.
Передати `$request->input('sort')` прямо в `orderBy()`, бо «Laravel бере колонку в лапки»: лапки ідентифікатора не є фільтром вводу, і користувач сортує за будь-якою колонкою, зокрема `password` чи `remember_token`.
Валідувати `sort` правилом `string|max:30`: рядок пройде, а питання було в тому, які саме колонки дозволені.
Поставити плейсхолдер на місце колонки (`orderByRaw('? desc', [$column])`): база отримає рядкову константу, сортування мовчки перестане працювати, помилки не буде.
ПОРАДА

Скажіть, що Laravel захищає значення, а не текст SQL: де ви самі пишете рядок запиту, там і ваша відповідальність. Одразу покажіть мапу для сортування, бо це питання майже завжди йде наступним.

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Біндінги з другого аргументу йдуть у PDO окремо від тексту запиту, тому значення не може стати частиною SQL. `DB::select()` справді викликає `prepare()`, але рядок уже склеєний з `$id`, і prepared statement отримує готову інʼєкцію. Лапки навколо колонки в `orderBy()` не фільтрують ввід: користувач і далі обирає будь-яку колонку таблиці. Плейсхолдер на місці колонки база бачить як рядкову константу, тож сортування не працює.