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:

⚠️ NIEBEZPIECZNE — NIGDY TAK NIE PISZ:
<?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?

⚠️ NIEBEZPIECZNE — NIGDY TAK NIE PISZ:
<?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.

⚠️ SQL injection to #1 na liście OWASP Top 10: Od lat jest najczęściej exploitowaną klasą podatności webowych. Prowadzi do wycieku danych, ich modyfikacji, usunięcia bazy, a nawet przejęcia serwera (przez funkcje SQL dające dostęp do systemu plików). Nie ma żadnego powodu, by ryzykować — prepared statements rozwiązują problem raz na zawsze.

Prepared statements — poprawne rozwiązanie

Zamiast sklejać SQL z danymi, oddzielasz strukturę zapytania od danych:

  1. prepare() — wysyłasz do bazy szablon zapytania z placeholderami zamiast wartości. Baza wie, gdzie będą dane, i kompiluje zapytanie.
  2. 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.

💡 Prepared statements chronią też przed błędami typów: PDO automatycznie cytuje łańcuchy tekstowe i przekazuje liczby bez cudzysłowów — nie musisz ręcznie uciekać znaków apostrofu ani pamiętać o formatowaniu dat. Dodatkowy bonus poza bezpieczeństwem.

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?

Który z poniższych kodów jest bezpieczny?

Jak przekazujesz wartości do execute() przy użyciu nazwanych placeholderów?

Ć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";
// }