Prepared statements — ochrona przed SQL injection
🎯 Po tej lekcji będziesz rozumieć SQL injection, używać prepare() z nazwanymi i pozycyjnymi placeholderami i wiedzieć, dlaczego sklejanie SQL jest niebezpieczne.
Do tej pory używałeś query() tylko dla zapytań z wartościami zapisanymi na stałe w kodzie. Co jednak, gdy chcesz filtrować dane wpisane przez użytkownika? Nie możesz po prostu wkleić $_GET['id'] do SQL. Dlaczego? Bo to jeden z najgroźniejszych ataków na aplikacje webowe — SQL injection.
SQL injection — co to jest?
Wyobraź sobie prosty formularz wyszukiwania zadań. Użytkownik wpisuje numer id, a ty pobierasz zadanie:
<?php
declare(strict_types=1);
// ZŁY KOD — podatny na SQL injection
$id = $_GET['id']; // użytkownik wpisał: 1 OR 1=1
$wynik = $pdo->query("SELECT * FROM zadania WHERE id = $id");
// Wykonane zapytanie: SELECT * FROM zadania WHERE id = 1 OR 1=1
// 1=1 jest zawsze prawdą — zwraca WSZYSTKIE wiersze!
A co jeśli użytkownik wpisze coś jeszcze gorszego?
<?php
declare(strict_types=1);
// ZŁY KOD — sklejanie łańcuchów SQL z danymi użytkownika
$szukaj = $_POST['szukaj']; // użytkownik wpisał: '; DROP TABLE zadania; --
$wynik = $pdo->query("SELECT * FROM zadania WHERE tytul = '$szukaj'");
// Wykonane zapytanie: SELECT * FROM zadania WHERE tytul = ''; DROP TABLE zadania; --'
// Wynik: usunięcie całej tabeli zadania!
Napastnik wstrzyknął własny kod SQL zakończony komentarzem --. Baza wykonała go posłusznie.
Prepared statements — poprawne rozwiązanie
Zamiast sklejać SQL z danymi, oddzielasz strukturę zapytania od danych:
prepare()— wysyłasz do bazy szablon zapytania z placeholderami zamiast wartości. Baza wie, gdzie będą dane, i kompiluje zapytanie.execute()— przekazujesz wartości. Baza wie, że to dane, nie kod SQL.
Nie ma żadnej możliwości, żeby dane stały się kodem SQL — baza traktuje je zawsze jako wartości.
Nazwane placeholdery — :nazwa
Czytelniejszy i preferowany styl:
<?php
declare(strict_types=1);
require 'db.php';
$id = (int) ($_GET['id'] ?? 0); // konwersja do int jako dodatkowe zabezpieczenie
$stmt = $pdo->prepare('SELECT id, tytul, opis FROM zadania WHERE id = :id');
$stmt->execute([':id' => $id]);
$zadanie = $stmt->fetch();
if ($zadanie === false) {
echo 'Nie znaleziono zadania.';
} else {
echo htmlspecialchars($zadanie['tytul'], ENT_QUOTES, 'UTF-8');
}
Możesz też opuścić dwukropek w kluczu tablicy — obie formy są równoważne:
$stmt->execute(['id' => $id]); // działa tak samo jak [':id' => $id]
Wiele placeholderów naraz
<?php
declare(strict_types=1);
require 'db.php';
$tytul = trim($_POST['tytul'] ?? '');
$ukonczone = (int) ($_POST['ukonczone'] ?? 0);
$stmt = $pdo->prepare(
'SELECT id, tytul FROM zadania WHERE tytul LIKE :tytul AND ukonczone = :ukonczone'
);
$stmt->execute([
':tytul' => '%' . $tytul . '%',
':ukonczone' => $ukonczone,
]);
$wyniki = $stmt->fetchAll();
Zwróć uwagę: % dla LIKE doklejasz do wartości parametru w PHP — nie wewnątrz SQL. Baza i tak wie, że to dane.
Pozycyjne placeholdery — ?
Alternatywny styl z pytajnikami. Wartości przekazujesz w tablicy w tej samej kolejności, co ? w zapytaniu:
<?php
declare(strict_types=1);
require 'db.php';
$id = (int) ($_GET['id'] ?? 0);
$stmt = $pdo->prepare('SELECT id, tytul, opis FROM zadania WHERE id = ?');
$stmt->execute([$id]);
$zadanie = $stmt->fetch();
Styl z ? jest zwięzły, ale przy wielu parametrach łatwiej się pomylić w kolejności. Zazwyczaj preferuj nazwane placeholdery — kod jest czytelniejszy.
Jak to działa pod spodem?
Gdy wywołujesz prepare(), PHP wysyła do bazy sam szablon zapytania:
SELECT id, tytul FROM zadania WHERE id = :id
Baza go kompiluje (parsuje, tworzy plan wykonania) bez żadnych danych. Potem execute(['id' => $id]) przekazuje wartość oddzielnym kanałem — jako parametr, nie jako tekst SQL. Baza nigdy nie interpretuje wartości jako kodu. Nieważne co wpisał użytkownik — jest bezpiecznie traktowane jako dane.
Podsumowanie — zasada bezwzględna
| Sytuacja | Metoda |
|---|---|
| Zapytanie tylko ze stałymi wartościami | query('SELECT * FROM zadania') |
| Zapytanie z jakimikolwiek danymi od użytkownika | prepare() + execute([...]) |
Jeśli masz choćby cień wątpliwości — użyj prepare(). Koszt jest znikomy, bezpieczeństwo — bezcenne.
W następnej lekcji zastosujesz prepared statements do pełnego zestawu operacji CRUD: INSERT, SELECT, UPDATE i DELETE.
Sprawdź się
Czym jest SQL injection?
SQL injection to atak, w którym napastnik umieszcza w polu formularza (lub parametrze URL) fragment SQL. Jeśli kod skleja dane bezpośrednio z zapytaniem, złośliwy SQL zostaje wykonany — można wykraść, zmodyfikować lub usunąć dane.
Który z poniższych kodów jest bezpieczny?
Tylko prepare() z placeholderem jest bezpieczne. W opcji 1 i 3 dane od użytkownika trafiają bezpośrednio do łańcucha SQL — to klasyczny SQL injection. PDO przy prepare()/execute() przekazuje dane oddzielnie od kodu SQL.
Jak przekazujesz wartości do execute() przy użyciu nazwanych placeholderów?
execute() przyjmuje tablicę asocjacyjną, gdzie klucz to nazwa placeholdera (z dwukropkiem lub bez) a wartość to dane. Obie formy są poprawne: [':id' => $id] i ['id' => $id].
Ćwiczenie
Napisz funkcję szukajZadanie(PDO $pdo, string $szukaj): array, która pobiera z tabeli zadania wszystkie wiersze, gdzie tytul zawiera podany fragment (LIKE). Użyj prepared statement z nazwanym placeholderem. Zwróć tablicę wyników. Pamiętaj, że znak % w LIKE musi być dodany do wartości parametru, nie do SQL.
Pokaż rozwiązanie
<?php
declare(strict_types=1);
function szukajZadanie(PDO $pdo, string $szukaj): array
{
$stmt = $pdo->prepare(
'SELECT id, tytul, opis FROM zadania WHERE tytul LIKE :szukaj ORDER BY id DESC'
);
$stmt->execute([':szukaj' => '%' . $szukaj . '%']);
return $stmt->fetchAll();
}
// Użycie (zakładamy $pdo z db.php):
// $wyniki = szukajZadanie($pdo, 'PHP');
// foreach ($wyniki as $z) {
// echo htmlspecialchars($z['tytul'], ENT_QUOTES, 'UTF-8') . "\n";
// }