Dlaczego bezpieczeństwo danych w PHP jest tak drażliwym tematem
Popularność PHP i efekt „legacy”
PHP jest jednym z najpopularniejszych języków do tworzenia aplikacji webowych. To jednocześnie zaleta i problem. Zaletą jest ogromna ilość materiałów, przykładów i bibliotek. Problem zaczyna się, gdy te materiały pochodzą sprzed kilkunastu lat i nadal są kopiowane do nowych projektów. Wiele tutoriali bazuje na starych wersjach PHP, nie uwzględnia przygotowanych zapytań, nowoczesnych funkcji bezpieczeństwa ani obecnych domyślnych konfiguracji.
Skutkiem jest ogromna ilość „żyjącego” legacy code: sklepy internetowe, panele administracyjne, małe aplikacje firmowe, które bazują na nieaktualnych wzorcach. Część z nich nigdy nie przeszła przeglądu bezpieczeństwa, bo „przecież działa”. To, że kod działa funkcjonalnie, nie oznacza wcale, że jest odporny na ataki – szczególnie przy obecnym poziomie automatyzacji skanów i botów szukających luk.
Do tego dochodzi łatwość startu z PHP: osoba ucząca się języka może w godzinę zbudować prosty formularz zapisujący dane do bazy, nie rozumiejąc, jak działa SQL Injection, XSS czy przechwytywanie sesji. To prowadzi do powielania niebezpiecznych wzorców, które z czasem trafiają na produkcję.
Typowe zagrożenia w aplikacjach PHP
Bezpieczne zarządzanie danymi w PHP oznacza ochronę przed kilkoma klasami zagrożeń, które w praktyce pojawiają się najczęściej:
- Wycieki danych – nieautoryzowany dostęp do baz danych, logów, plików z konfiguracją lub backupów. Czasem przez błędy w dostępie do plików, czasem przez luki typu SQL Injection.
- Przejęcie kont użytkowników – ataki na formularze logowania, słabe haszowanie haseł, kradzież sesji, reset hasła bez odpowiedniej weryfikacji.
- Zdalne wykonanie kodu (RCE) – wynikające z niebezpiecznego użycia funkcji typu
eval,system,exec,includena danych kontrolowanych przez użytkownika lub błędów w konfiguracji serwera. - Ataki XSS i CSRF – wstrzyknięcie złośliwego JavaScriptu (XSS) albo wymuszenie na zalogowanym użytkowniku wykonania żądania bez jego świadomości (CSRF).
Każda z tych kategorii ma bezpośredni wpływ na dane: ich poufność, integralność i dostępność. W praktycznych projektach często bardziej boli utrata czy modyfikacja danych (np. zamówień, konfiguracji) niż sam chwilowy brak dostępności aplikacji.
„To działa” kontra „to działa bezpiecznie”
Kod, który po prostu „działa”, zwykle ignoruje model zagrożeń. Przyjmuje, że użytkownik będzie używał aplikacji „zgodnie z jej przeznaczeniem”. Bezpieczny kod jest pisany z założeniem, że:
- część użytkowników będzie próbowała wstrzyknąć złośliwy kod,
- parametry żądań mogą być dowolnie modyfikowane,
- cookies i sesje mogą być podkradane lub podmieniane,
- wewnętrzne API może zostać wywołane spoza przewidzianego kontekstu.
Różnica jest subtelna, ale kluczowa: każda operacja na danych – czy to dane wejściowe, dane w bazie, czy pliki – musi być przemyślana z perspektywy, co się stanie, jeśli użytkownik nie zachowuje się „uczciwie”. Kod bez tej refleksji jest łatwym celem.
Model zagrożeń: co chronimy i przed kim
Zanim zacznie się wprowadzać kolejne warstwy zabezpieczeń, sensownie jest odpowiedzieć sobie na kilka pytań:
- Jakie dane są krytyczne?
Hasła, dane osobowe, numery telefonów, tokeny API, identyfikatory sesji, dane transakcyjne, konfiguracje aplikacji. - Przed kim chronimy?
Przed anonimowymi botami skanującymi sieć, przed nieautoryzowanymi użytkownikami, ale też – czasem – przed nieuczciwymi użytkownikami z kontem w systemie. - Jakie scenariusze ataku są realne?
Próby SQL Injection, odgadywanie haseł, skrypt wklejony w pole komentarza, link wysłany mailem, który po kliknięciu wykona akcję na koncie użytkownika.
Dopiero w tym kontekście można sensownie ocenić, czy dana praktyka jest niezbędna, czy jest nadgorliwością, oraz jakie kompromisy są akceptowalne (np. w wydajności czy złożoności kodu).

Bezpieczna konfiguracja środowiska PHP i serwera
Kluczowe dyrektywy w php.ini
Konfiguracja PHP to pierwsza linia obrony. Nawet dobrze napisany kod można osłabić niewłaściwymi ustawieniami. Kilka dyrektyw ma bezpośredni wpływ na bezpieczeństwo:
display_errors– na produkcji powinno być ustawione naOff. Wyświetlanie błędów bezpośrednio w przeglądarce może ujawnić struktury katalogów, fragmenty kodu, informacje o bazie danych.error_log– powinno wskazywać na plik (lub system logowania), do którego ma dostęp tylko administrator. Logi nie powinny być dostępne przez HTTP.expose_php– ustawienieOffukrywa wersję PHP w nagłówkach HTTP. Nie rozwiązuje to problemów bezpieczeństwa, ale utrudnia automatyczne skanowanie pod konkretne exploity.allow_url_fopeniallow_url_include–allow_url_includebezwzględnieOff. Zdalne include to klasyczny wektor RCE.allow_url_fopenbywa potrzebne (np. do pobierania plików), ale wymaga ostrożności przy operacjach na danych zewnętrznych.disable_functions– można zablokować funkcje typuexec,shell_exec,system,passthru, jeśli aplikacja ich nie potrzebuje. To nie jest panaceum, ale ogranicza skutki niektórych klas błędów.
Te ustawienia nie zastępują poprawnego kodu, ale zmniejszają powierzchnię ataku i utrudniają eskalację w przypadku znalezienia luki.
Oddzielenie środowisk: development kontra produkcja
Środowisko developerskie i produkcyjne mają sprzeczne wymagania. Programiście wygodniej jest widzieć pełne błędy i stack trace; użytkownik końcowy nie powinien widzieć nic poza ogólnym komunikatem. Sensowna praktyka to:
- osobne
php.inilub.iniper FPM pool dla dev i prod, - w dev:
display_errors = On, szczegółoweerror_reporting, - w prod:
display_errors = Off, wszystkie błędy logowane do pliku, ograniczony dostęp do logów.
Nagminną pułapką jest deploy aplikacji z ustawieniami deweloperskimi „na chwilę”, które nigdy nie zostają odwrócone. Automatyzacja konfiguracji (np. przez Ansible, Docker, provisioning) jest bardziej godna zaufania niż ręczne zmiany na serwerze.
Konfiguracja serwera WWW: Apache / Nginx
Konfiguracja serwera HTTP jest równie istotna jak php.ini. Kilka podstawowych reguł:
- Brak listowania katalogów –
Options -Indexesw Apache, odpowiednia dyrektywa w Nginx. Użytkownik nie powinien móc zobaczyć spisu plików, jeśli brak tamindex.php. - Ochrona plików konfiguracyjnych – pliki
.env,config.php, pliki z backupami SQL nie mogą być serwowane przez HTTP. - Oddzielenie katalogu publicznego – aplikacja powinna mieć katalog
publiclubpublic_html, do którego wskazuje root serwera; reszta plików aplikacji pozostaje poza zasięgiem przeglądarki.
| Element | Środowisko development | Środowisko produkcja |
|---|---|---|
| display_errors | On | Off |
| error_reporting | E_ALL | E_ALL (logowane, nie wyświetlane) |
| dostęp do logów | Programiści | Tylko administrator / monitoring |
| root serwera | projekt (często uproszczony) | tylko katalog publiczny |
Przykładowa konfiguracja produkcyjna PHP (skrót)
Fragment sensownej konfiguracji dla środowiska produkcyjnego może wyglądać następująco:
; Bez wyświetlania błędów użytkownikowi
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
; Ukrycie wersji PHP
expose_php = Off
; Zdalne include zabronione, zdalne fopen rozważnie
allow_url_fopen = On
allow_url_include = Off
; Sesje – część ustawień (reszta w odpowiedniej sekcji)
session.use_strict_mode = 1
session.use_only_cookies = 1
; Ograniczenie funkcji systemowych (jeśli nie są potrzebne)
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
Wyłączenie wszystkich potencjalnie niebezpiecznych funkcji bywa nadgorliwe, zwłaszcza w aplikacjach korzystających z zewnętrznych narzędzi (np. generowanie PDF przez binarki systemowe). Sensowny balans polega na tym, by blokować to, co realnie nie jest używane, zamiast ślepo kopiować listy z Internetu.
Podstawy modelu zaufania: skąd dane i co z nimi robić
Wszystkie dane wejściowe są nieufne
Najprostsza zasada to: każde dane, które nie pochodzą bezpośrednio z zaufanego źródła aplikacji, są potencjalnie złośliwe. W praktyce dotyczy to:
$_GET– parametry w URL, łatwe do modyfikacji przez każdego,$_POST– dane z formularzy, również łatwe do podrobienia,$_COOKIE– dane zapisane w przeglądarce, podatne na manipulację,$_FILES– uploadowane pliki, gdzie nazwa, typ MIME i zawartość mogą być dowolne,php://input– surowe ciało żądania (np. JSON), bez gwarancji formatów,- nagłówki HTTP –
User-Agent,X-Forwarded-For, itd., łatwe do podrobienia.
Jedynym względnie zaufanym źródłem są dane generowane przez samą aplikację po walidacji (np. rekordy z bazy danych, jeśli zapisy do bazy są odpowiednio chronione). Nawet wtedy integracje z zewnętrznymi usługami wymagają walidacji odpowiedzi.
Walidacja kontra sanityzacja
Te dwa pojęcia bywają mylone, a pełnią różne role:
- Walidacja – sprawdza, czy dane są poprawne według określonego zestawu reguł. Przykład: „wiek musi być liczbą całkowitą między 18 a 99”. Jeśli warunek nie jest spełniony – dane są odrzucane.
- Sanityzacja (oczyszczanie) – modyfikuje dane, by dopasować je do bezpiecznego formatu. Przykład: przycinanie spacji, usuwanie znaków spoza białej listy, escapowanie znaków przed wyświetleniem w HTML.
Częsta pułapka: użycie „magicznego oczyszczania” zamiast walidacji. Próba ratowania wszystkiego przez htmlspecialchars lub regex bez jasnego zdefiniowania, co jest dozwolone. To zwykle kończy się przepuszczeniem części złośliwych danych, bo reguły są zbyt ogólne.
Zasada najmniejszego zaufania
Bezpieczne zarządzanie danymi w PHP dobrze opiera się na białych listach. Zamiast próbować blokować pojedyncze niebezpieczne konstrukcje, definiuje się, co jest dozwolone i odrzuca całą resztę. Przykłady:
- login użytkownika może zawierać tylko litery, cyfry i
_, o długości 3–32 znaki, - identyfikator sortowania może przyjąć wartość jedynie z listy:
['date', 'price', 'name'], - status zamówienia może być jednym z:
['new', 'paid', 'shipped', 'cancelled'].
Takie podejście redukuje liczbę wyjątków i sprawia, że ewidentnie nieoczekiwane wartości (np. wstrzyknięty SQL, HTML, kod JavaScript) zostają odrzucone na wejściu. Nie ma tu miejsca na „trochę niepoprawne, więc spróbujemy to poprawić” – jeśli dane nie przechodzą walidacji, odpowiedź jest odmowna.
Przykład: prosty formularz logowania
Rozważ krótki przykład:
Walidacja formularza logowania krok po kroku
Przy prostym formularzu logowania dominuje pokusa, by „po prostu pobrać login i hasło i sprawdzić w bazie”. To dobry przepis na kłopoty. Sensowniejsze jest wydzielenie kilku etapów: wczytanie danych, walidacja, normalizacja i dopiero potem porównanie z danymi w bazie.
<?php
// login.php (fragment)
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$loginRaw = $_POST['login'] ?? '';
$passwordRaw = $_POST['password'] ?? '';
// 1. Normalizacja "miękka" – usunięcie spacji po bokach
$login = trim($loginRaw);
$errors = [];
// 2. Walidacja logina według białej listy
if ($login === '') {
$errors['login'] = 'Login jest wymagany.';
} elseif (!preg_match('/^[a-zA-Z0-9_]{3,32}$/', $login)) {
$errors['login'] = 'Login ma nieprawidłowy format.';
}
// 3. Walidacja hasła – tutaj tylko minimalne wymagania
if ($passwordRaw === '') {
$errors['password'] = 'Hasło jest wymagane.';
} elseif (mb_strlen($passwordRaw) < 8) {
$errors['password'] = 'Hasło musi mieć co najmniej 8 znaków.';
}
if ($errors) {
// Błędne dane wejściowe – nie dotykamy bazy
// Tutaj renderowanie formularza z błędami
// ...
exit;
}
// 4. Dalej dopiero logika pobrania użytkownika z bazy i weryfikacji hasła
// ...
}
Tu walidacja logina nie „naprawia” niepoprawnych znaków, tylko wprost odrzuca wartość. To mniej wygodne dla użytkownika, ale przewidywalne z punktu widzenia bezpieczeństwa.
Walidacja po stronie serwera kontra walidacja po stronie klienta
Walidacja w JavaScript poprawia UX, ale z punktu widzenia bezpieczeństwa nie ma znaczenia. Formularz można wysłać bez JS, przez curl albo zmodyfikować w narzędziach developerskich. Serwer musi być przygotowany na każde dane, niezależnie od tego, jak eleganckie regexy stoją po stronie klienta.
Praktyczny układ bywa taki:
- klient waliduje pola pod kątem wygody (podpowiedzi, format, szybkie błędy),
- serwer wykonuje pełną i ostateczną walidację, w oparciu o reguły zdefiniowane w kodzie backendu.
Jeżeli serwer zakłada, że „frontend już to sprawdził”, to prędzej czy później ktoś prześle mu payload, który ten założony filtr ominie.

Walidacja i filtrowanie danych w PHP – praktyczne wzorce
Filtrowanie wbudowane: filter_input i filter_var
PHP ma wbudowany mechanizm filtrów, który często jest ignorowany na rzecz ręcznych ifów i regexów. Nie rozwiązuje wszystkiego, ale daje solidny start:
<?php
// Bezpośrednio z superglobali (mniej wygodne przy testach)
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
// Z użyciem przygotowanych danych
$rawIp = $_SERVER['REMOTE_ADDR'] ?? '';
$ip = filter_var($rawIp, FILTER_VALIDATE_IP, [
'options' => ['default' => null],
]);
Typowy błąd: używanie filtrów „oczyszczających” tam, gdzie bardziej sensowne jest walidowanie i odrzucanie. FILTER_SANITIZE_STRING bywa traktowany jak magiczna tarcza przeciw XSS, co nie ma wiele wspólnego z rzeczywistością. Do ochrony widoku przed XSS służy escapowanie przy wyświetlaniu, a nie „magiczne obcinanie” danych przy wejściu.
Implementacja białych list dla parametrów
Przy parametrach takich jak sortowanie, kierunek sortowania czy tryb widoku, opieranie się na regexach ma mniejszy sens niż trzymanie jawnej listy dopuszczalnych wartości.
<?php
$allowedSort = ['date', 'price', 'name'];
$allowedDir = ['asc', 'desc'];
$sort = $_GET['sort'] ?? 'date';
$dir = $_GET['dir'] ?? 'asc';
if (!in_array($sort, $allowedSort, true)) {
$sort = 'date'; // albo błąd 400, zależnie od wymagań
}
if (!in_array($dir, $allowedDir, true)) {
$dir = 'asc';
}
// Dalej sortowanie z użyciem $sort i $dir
Bez takiej białej listy kończy się na klejeniu stringów w SQL typu ORDER BY ".$_GET['sort'], co prędzej czy później zamieni się w podatność na wstrzyknięcie.
Walidacja liczb całkowitych i zakresów
Najczęstszy błąd przy liczbach całkowitych to ufanie rzutowaniu. Konstrukcja (int) $_GET['id'] nie jest walidacją, tylko konwersją. Dla pustego stringa, abc czy postaci typu 1e6 wynik może być zaskakujący.
<?php
$idRaw = $_GET['id'] ?? null;
$id = filter_var($idRaw, FILTER_VALIDATE_INT, [
'options' => [
'min_range' => 1,
'max_range' => 1000000,
],
]);
if ($id === false) {
http_response_code(400);
exit('Nieprawidłowy identyfikator.');
}
Filtr jasno zwraca false, jeśli wartość nie przechodzi, zamiast tworzyć wrażenie, że „jakoś się skonwertowało, więc jest ok”.
Oczyszczanie danych wyjściowych zamiast „naprawiania” wejścia
Typowy anty-wzorzec: $_POST jest masowo przepuszczany przez htmlspecialchars() zaraz po odebraniu żądania, a potem takie „oczyszczone” dane są zapisywane do bazy. Pojawiają się wtedy podwójne escape’y, niespójności i problemy przy eksporcie danych. Sensowniejszy schemat jest odwrotny:
- w bazie przechowuje się dane w formie surowej (po walidacji),
- dla każdego kontekstu wyjściowego stosuje się osobne escapowanie:
- HTML:
htmlspecialchars(), - atrybuty HTML:
htmlspecialchars()+ dodatkowa kontrola znaków, - JavaScript (np. dane osadzone w JS): kodowanie JSON-em,
json_encode(), - URL:
rawurlencode()czy odpowiednie funkcje frameworka.
- HTML:
To nie jest nadmierny formalizm – mieszanie warstw wejścia i wyjścia powoduje, że danych nie da się później użyć w innym kontekście bez błędów lub kolejnych „napraw”.
Własne klasy walidatorów i obiekty wartości (value objects)
W większych projektach ręczne walidowanie każdego pola w kontrolerze szybko wymyka się spod kontroli. Jednym ze sposobów na utrzymanie porządku jest tworzenie klas reprezentujących konkretne typy danych domenowych, np. Email, UserLogin, OrderStatus. Konstruktor takiej klasy wykonuje walidację, a reszta kodu operuje już na obiekcie, nie na dowolnym stringu.
<?php
final class UserLogin
{
private string $value;
public function __construct(string $value)
{
$value = trim($value);
if (!preg_match('/^[a-zA-Z0-9_]{3,32}$/', $value)) {
throw new InvalidArgumentException('Nieprawidłowy login.');
}
$this->value = $value;
}
public function __toString(): string
{
return $this->value;
}
}
// Użycie:
try {
$login = new UserLogin($_POST['login'] ?? '');
} catch (InvalidArgumentException $e) {
// Błąd walidacji, komunikat dla użytkownika
}
To podejście jest cięższe na start, ale ogranicza rozjeżdżanie się logiki walidacji w dziesiątkach miejsc.
Bezpieczne operacje na bazie danych: PDO, prepared statements i pułapki
Dlaczego prepared statements to punkt wyjścia, a nie „opcjonalny bajer”
Ręczne sklejanie SQL z danymi użytkownika jest nadal powszechne, mimo że każda dokumentacja bezpieczeństwa bije w ten bęben od lat. Wyjątki typu dynamiczne nazwy tabel czy kolumn to margines, nie reguła. W 95% przypadków użycie parametrów wiązanych załatwia problem wstrzyknięcia SQL na poziomie bazy.
<?php
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE login = :login');
$stmt->execute(['login' => $login]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
Parametr :login nigdy nie będzie traktowany jako część struktury zapytania, niezależnie od zawartości wartości. Nie dotyczy to jednak fragmentów takich jak nazwy tabel, kolumn czy słowa kluczowe – dlatego losowo nie da się „zabezpieczyć” wszystkiego parametrami i jednocześnie mieć dynamicznego SQL-a z dowolnymi kawałkami.
Dynamiczny SQL: kiedy jest potrzebny i jak sobie z nim radzić
Są sytuacje, gdy fragment SQL-a naprawdę musi być zbudowany dynamicznie – np. wybór kolumny do sortowania. W takich miejscach parametry wiązane nie pomogą, bo placeholdery dotyczą tylko wartości. Jedynym sensownym wyjściem jest kombinacja białych list i parametrów:
<?php
$allowedSort = ['date_created', 'price', 'name'];
$sort = $_GET['sort'] ?? 'date_created';
if (!in_array($sort, $allowedSort, true)) {
$sort = 'date_created';
}
$sql = "SELECT * FROM products ORDER BY {$sort} DESC LIMIT :limit";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', 20, PDO::PARAM_INT);
$stmt->execute();
Kolumna ORDER BY jest kontrolowana przez białą listę, a liczba rekordów przez parametr wiązany. Sklejanie całości z dowolnego stringa od użytkownika kończy się w najlepszym razie błędem SQL, a w gorszym – pełnym wstrzyknięciem.
Tryby błędów PDO i konsekwencje dla bezpieczeństwa
Domyślny tryb PDO zwykle zwraca kody błędów zamiast rzucać wyjątki. W praktyce prowadzi to do sytuacji, w której część błędów jest dyskretnie ignorowana. Wyłączenie błędów SQL w logach to nie jest „poprawa bezpieczeństwa” – raczej ukrycie sygnałów, że coś jest mocno nie tak.
<?php
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
W produkcji sensownie jest logować wyjątki i zwracać użytkownikowi ogólny komunikat. W dev – pokazywać szczegóły. Mieszanie tych dwóch światów, np. pełne trace’e SQL na produkcji, to klasyczny przykład przecieku informacji.
Emulowane przygotowane zapytania a bezpieczeństwo
PDO ma opcję ATTR_EMULATE_PREPARES. Jeśli jest włączona, część driverów emuluje prepared statements po stronie PHP, a do bazy wysyła już gotowy SQL. Z perspektywy bezpieczeństwa może to osłabić ochronę przed pewnymi, rzadkimi wariantami ataków, choć główne ryzyko i tak leży po stronie złego użycia API.
<?php
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // jeśli driver i DB wspierają
]);
Ustawienie false nie jest magiczną poprawą bezpieczeństwa, ale daje bardziej przewidywalne działanie przy specyficznych typach i niestandardowych konstrukcjach.
Wielokrotne wstawianie danych i transakcje
Przy wielu insertach w ramach jednej operacji (np. tworzenie zamówienia i pozycji zamówienia) istotne są dwie rzeczy: parametry wiązane oraz transakcje. Bez transakcji część danych może się zapisać, a część nie – i powstaje bałagan, który później trudno sprzątnąć.
<?php
$pdo->beginTransaction();
try {
$orderStmt = $pdo->prepare(
'INSERT INTO orders (user_id, total_amount) VALUES (:user_id, :total)'
);
$orderStmt->execute([
'user_id' => $userId,
'total' => $totalAmount,
]);
$orderId = (int) $pdo->lastInsertId();
$itemStmt = $pdo->prepare(
'INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (:order_id, :product_id, :qty, :price)'
);
foreach ($items as $item) {
$itemStmt->execute([
'order_id' => $orderId,
'product_id' => $item['product_id'],
'qty' => $item['quantity'],
'price' => $item['price'],
]);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
// Logowanie błędu, komunikat ogólny dla użytkownika
}
Transakcja nie jest bezpośrednim mechanizmem bezpieczeństwa, ale redukuje skutki błędów i ataków, które mogłyby zostawić dane w niespójnym stanie.
Unikanie wycieków danych w komunikatach SQL
Przy obsłudze błędów SQL pojawia się pokusa, by przekazać użytkownikowi treść wyjątku. W dev to bywa wygodne, w produkcji – ryzykowne. Komunikaty mogą zawierać fragmenty SQL, nazwy tabel, a czasem wręcz wartości danych. Bezpieczniejszy schemat:
- w logach: pełny wyjątek, SQL, parametry (z ewentualnym maskowaniem wrażliwych),
Ograniczanie uprawnień bazy i separacja danych
Bez wiązanych parametrów nie ma mowy o bezpieczeństwie, ale same prepared statements nie załatwią wszystkiego. Jeśli aplikacja łączy się z bazą jako użytkownik z pełnymi uprawnieniami, to jedna luka w SQL może oznaczać nie tylko odczyt, ale i modyfikację, a nawet usuwanie danych. Realistyczne minimum to osobny użytkownik bazy dla aplikacji, z możliwie wąskim zakresem uprawnień.
- aplikacja frontendowa (np. panel klienta) – zwykle
SELECT, ograniczoneINSERT/UPDATE, brakDROP,ALTER, - zadania administracyjne (cron, migracje) – osobny użytkownik, inne poświadczenia, dostęp tylko z zaufanego hosta,
- dane mocno wrażliwe (np. logi audytowe) – najlepiej osobny schemat lub wręcz inna baza.
W praktyce oznacza to trochę więcej konfiguracji, ale skutki potencjalnego wycieku są znacznie mniejsze. Atakujący z SQL injection w module „publicznym” nie powinien mieć możliwości zdropowania całej bazy ani odczytu każdej tabeli w systemie.
Lazy loading, N+1 i bezpieczeństwo wydajnościowe
Bezpieczeństwo danych to również odporność na prosty DoS wywołany nieprzemyślanym SQL-em. Klasyczny przykład: lista użytkowników, do której przy każdym rekordzie dociągana jest osobno rola, adres, preferencje. Dopóki w bazie jest kilkudziesięciu użytkowników, nikt nie narzeka; gdy liczba rekordów rośnie, zapytanie zaczyna dusić bazę.
Tu nie chodzi o „ładny” kod, tylko o to, że takie N+1 potrafi zużyć zasoby w stopniu porównywalnym z atakiem. Gdy użytkownik może sterować np. liczbą elementów na stronie, nieoptymalne zapytanie mnoży się razy setki.
- stosowanie paginacji z rozsądnym limitem maksymalnym (np. 50–100 rekordów na stronę),
- łączenie danych jednym, dobrze przemyślanym zapytaniem zamiast dziesiątek małych,
- asynchroniczne doczytywanie rzadko używanych szczegółów (np. szczegóły zamówienia po kliknięciu).
To zagadnienie jest często spychane do kategorii „wydajności”, ale w środowisku realnym: przeciążona baza = brak usług = naruszenie ciągłości działania. Dla klienta nie ma znaczenia, czy serwer „padł przez atak”, czy przez błędny kod.

Hasła użytkowników i wrażliwe dane – poprawne przechowywanie i porównywanie
Hashowanie haseł w PHP: co jest obecnym standardem
Ręczne „solenie” i używanie gołego sha1() czy md5() nie broni już praktycznie przed niczym. Aktualnym minimum w PHP jest użycie password_hash() i password_verify(). Mechanizm dba o sól, odpowiedni schemat i możliwość migracji algorytmu w przyszłości.
<?php
// Rejestracja / zmiana hasła
$passwordHash = password_hash($plainPassword, PASSWORD_DEFAULT);
// Zapis do bazy
$stmt = $pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id');
$stmt->execute([
'hash' => $passwordHash,
'id' => $userId,
]);
// Logowanie
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE login = :login');
$stmt->execute(['login' => $login]);
$user = $stmt->fetch();
if (!$user || !password_verify($plainPassword, $user['password_hash'])) {
// Błędny login lub hasło
}
PASSWORD_DEFAULT jest przesuwającym się celem – w kolejnych wersjach PHP może oznaczać inny algorytm (np. bcrypt, Argon2), ale zawsze sensowny na dany moment. Próby bycia „mądrzejszym” niż wbudowana biblioteka zazwyczaj kończą się gorzej, chyba że ktoś rzeczywiście siedzi w kryptografii na co dzień.
Dobór kosztu hashowania i konsekwencje
Parametr kosztu (np. cost dla bcrypt, memory_cost/time_cost dla Argon2) jest kompromisem między bezpieczeństwem a wydajnością. Nie istnieje jedno „prawidłowe” ustawienie, które będzie dobre dla każdej aplikacji.
<?php
$options = [
'cost' => 12, // dla bcrypt, przykład – trzeba przetestować na danym serwerze
];
$passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT, $options);
Rozsądne podejście to testowanie: ile ms zajmuje pojedyncze password_hash() przy danym koszcie na serwerach produkcyjnych. Mowa o dziesiątkach, a nie setkach milisekund. Zbyt niski koszt ułatwia łamanie haseł przy wycieku bazy; zbyt wysoki może otworzyć furtkę do prostego ataku DoS poprzez masowe próby logowania.
Migracja starych hashy bez wymuszania resetu haseł
Rzadko kiedy projekt startuje od idealnej konfiguracji. Typowa sytuacja: w bazie leżą stare hashe z md5(), ale chcemy przejść na password_hash() bez masowego resetu haseł. Najbezpieczniejszy scenariusz to migracja przy najbliższym logowaniu użytkownika.
<?php
// Przykład schematu: kolumna legacy_algorithm, aby rozpoznać stary hash
if ($user['legacy_algorithm'] === 'md5') {
if (md5($plainPassword) === $user['password_hash']) {
// Uwierzytelnianie OK – od razu migracja
$newHash = password_hash($plainPassword, PASSWORD_DEFAULT);
$update = $pdo->prepare(
'UPDATE users
SET password_hash = :hash, legacy_algorithm = NULL
WHERE id = :id'
);
$update->execute([
'hash' => $newHash,
'id' => $user['id'],
]);
} else {
// Błąd hasła
}
} else {
if (!password_verify($plainPassword, $user['password_hash'])) {
// Błąd hasła
} elseif (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
// Opcjonalna aktualizacja parametru cost/algorytmu
$newHash = password_hash($plainPassword, PASSWORD_DEFAULT);
// UPDATE ...
}
}
Taki schemat pozwala z czasem „wyczyścić” bazę z historycznych, słabszych hashy, bez naruszania doświadczenia użytkownika. Warunek: stary mechanizm musi być znany i poprawnie zaimplementowany, inaczej migracja stanie się losowa.
Bezpieczne porównywanie tokenów i sekretów
Porównywanie haseł przejmuje password_verify(), ale w aplikacji pojawiają się także inne wrażliwe ciągi znaków: tokeny resetu hasła, tokeny API, klucze podpisujące. Użycie zwykłego === w takim kontekście niesie ryzyko ataków typu timing attack, zwłaszcza gdy aplikacja działa w wielu instancjach i niektóre porównania trwają zauważalnie dłużej.
<?php
$expectedToken = $row['reset_token']; // np. z bazy
$providedToken = $_GET['token'] ?? '';
if (!hash_equals($expectedToken, $providedToken)) {
// Token nieprawidłowy
}
hash_equals() dba o to, aby czas wykonania porównania był niezależny od miejsca pierwszej różnicy, co utrudnia ataki oparte na analizie czasu odpowiedzi. Różnica jest subtelna, ale stosunkowo tania w implementacji, więc trudno o usprawiedliwienie, by tego nie robić w nowych projektach.
Przechowywanie dodatkowych danych wrażliwych: nie wszystko musi być hashowane
Hasło z definicji jest dane jednorazowym kierunku – po stronie serwera nie ma być możliwe odzyskanie go wprost. Inne dane, jak numer telefonu, adres, czasem część numeru dokumentu, powinny pozostać czytelne dla aplikacji, więc hash nie rozwiązuje problemu. W takich przypadkach w grę wchodzi szyfrowanie z użyciem klucza aplikacyjnego.
Rozsądny kompromis to:
- hashowanie tego, czego nigdy nie trzeba odzyskiwać (hasła),
- szyfrowanie tego, co musi być odczytywane, ale jest wrażliwe (np. część danych osobowych),
- maskowanie przy wyświetlaniu (np. ostatnie cyfry numeru karty, z zachowaniem zgodności z regulacjami).
W PHP oznacza to zwykle użycie rozsądnie dobranej biblioteki (np. libsodium lub rozszerzenia sodium), a nie samodzielne wymyślanie schematu na openssl_encrypt() bez zrozumienia trybów, IV i uwierzytelniania. Projekt, który zaczyna od „napiszmy własną warstwę kryptografii”, najczęściej kończy z iluzją bezpieczeństwa.
Sesje i uwierzytelnianie w PHP – minimalny poziom bezpieczeństwa
Konfiguracja sesji: cookie zamiast ID w URL
PHP historycznie wspierało przenoszenie session_id w URL (tzw. session ID in URL). Obecnie to anachronizm i prosta droga do przejęcia sesji przy każdym logu serwera lub przeklejonym linku. Pierwszym krokiem jest upewnienie się, że identyfikator sesji trafia wyłącznie do ciasteczka.
<?php
ini_set('session.use_cookies', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
session_set_cookie_params([
'lifetime' => 0, // do zamknięcia przeglądarki
'path' => '/',
'domain' => 'example.com',
'secure' => true, // tylko przez HTTPS
'httponly' => true, // niedostępne z JS
'samesite' => 'Lax', // lub 'Strict' / 'None' (z dodatkowymi warunkami)
]);
session_start();
secure wymusza wysyłanie ciasteczka wyłącznie przez HTTPS, httponly ogranicza ekspozycję w razie XSS, a samesite pomaga przy atakach CSRF. To nie jest srebrna kula, ale podnosi poprzeczkę dla prostych scenariuszy przejęcia sesji.
Zmiana identyfikatora sesji po logowaniu i przy zmianie uprawnień
Typową, nadal powtarzaną luką jest session fixation – atakujący „wstrzykuje” ofierze znane sobie session_id, a aplikacja po zalogowaniu nie zmienia identyfikatora sesji. Efekt: napastnik korzysta z tej samej sesji, którą ofiara właśnie uwierzytelniła.
<?php
// Po udanym logowaniu:
session_regenerate_id(true);
// Ustawienie informacji o użytkowniku
$_SESSION['user_id'] = $user['id'];
$_SESSION['roles'] = $user['roles'];
true w session_regenerate_id() kasuje poprzednie ID na serwerze, więc stare sesje nie wiszą w nieskończoność w plikach. Warto tę funkcję wywoływać nie tylko przy logowaniu, ale też przy zmianie poziomu uprawnień (np. wejście w tryb administracyjny).
Przechowywanie danych w sesji: minimalizm i serializacja
Pokusa, by w sesji trzymać „wszystko, co przyjdzie do głowy”, pojawia się dość szybko. Łatwiej jest wrzucić do $_SESSION całego użytkownika z bazy, niż w każdej akcji dociągać tylko potrzebny fragment. Problem zaczyna się, gdy obiekt się zmienia, schemat bazy ewoluuje, a w sesji zostają stare, zserializowane wersje.
Bezpieczniejsza praktyka to trzymanie:
- krótkiego identyfikatora użytkownika (np.
user_id), - ewentualnie cache’owanych, prostych informacji (login, flaga „2FA enabled”),
- bez bezpośredniego przechowywania pełnych obiektów ORM czy całych encji.
Jeśli aplikacja używa niestandardowych mechanizmów serializacji (np. własnych handlerów sesji), każdy błąd w tym miejscu może prowadzić do deserializacji nieoczekiwanych klas. To z kolei otwiera drzwi do tzw. object injection, czyli wykonywania kodu podczas deserializacji. Lepiej tego uniknąć, niż później łatać kolejne wektory.
Timeout sesji, „pamiętaj mnie” i logowanie automatyczne
Bezterminowe sesje to wygoda, za którą płaci się zwiększonym ryzykiem przejęcia konta przy zgubionym lub współdzielonym urządzeniu. Z drugiej strony, zbyt agresywne wygaszanie sesji powoduje irytację użytkowników i prowokuje ich do obchodzenia zabezpieczeń (np. zapisywania haseł w notatniku).
Minimalny kompromis obejmuje:
- rozsądny idle timeout (np. kilkanaście–kilkadziesiąt minut braku aktywności),
- mechanizm „pamiętaj mnie” oparty na osobnym, silnym, losowym tokenie, trzymanym w cookie,
- możliwość ręcznego wylogowania ze wszystkich urządzeń (inwalidacja tokenów).
<?php
// Po zalogowaniu, przy zaznaczonym „pamiętaj mnie”
$selector = bin2hex(random_bytes(9));
$validator = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $validator);
$stmt = $pdo->prepare(
'INSERT INTO remember_tokens (user_id, selector, token_hash, expires_at)
VALUES (:user_id, :selector, :token_hash, :expires_at)'
);
$stmt->execute([
'user_id' => $user['id'],
'selector' => $selector,
'token_hash' => $tokenHash,
'expires_at' => (new DateTime('+30 days'))->format('Y-m-d H:i:s'),
]);
setcookie(
'remember_me',
$selector . ':' .
Najczęściej zadawane pytania (FAQ)
Jakie są najczęstsze błędy bezpieczeństwa w starych aplikacjach PHP?
W starszych projektach PHP zwykle pojawia się kilka powtarzalnych problemów: brak przygotowanych zapytań (surowe SQL z $_GET/$_POST), mieszanie logiki z HTML-em bez filtrowania danych wyjściowych (XSS), słabe haszowanie haseł (md5, sha1 bez soli) oraz używanie niebezpiecznych funkcji typu eval(), include na danych użytkownika.
Często dochodzi do tego domyślna, „luźna” konfiguracja serwera: włączone wyświetlanie błędów na produkcji, pliki backupów dostępne z poziomu przeglądarki, wspólny katalog root dla całego projektu zamiast wydzielonego katalogu public. To nie są pojedyncze wpadki, tylko efekt kopiowania starych tutoriali bez krytycznego spojrzenia.
Jak skonfigurować php.ini pod kątem bezpieczeństwa na produkcji?
Na produkcji podstawą jest wyłączenie wyświetlania błędów i przeniesienie ich do logów: display_errors = Off, log_errors = On, error_log = /ścieżka/poza/www. Dodatkowo warto ograniczyć raportowane typy błędów tak, by ostrzeżenia deweloperskie nie zalewały logów (np. wykluczenie E_DEPRECATED i E_STRICT, jeśli nie są aktualnie analizowane).
Kolejne sensowne ustawienia to expose_php = Off, allow_url_include = Off, ostrożne użycie allow_url_fopen oraz blokada nieużywanych funkcji systemowych przez disable_functions (np. exec, shell_exec, system). To nie zastąpi bezpiecznego kodu, ale zmniejszy szkody przy ewentualnym błędzie.
Jak odróżnić środowisko development od produkcji w PHP?
W praktyce stosuje się oddzielne konfiguracje i osobne entry pointy. Najprostszy wariant to dwa pliki konfiguracyjne (np. .env.local i .env.prod) oraz przełączanie ustawień na podstawie zmiennej środowiskowej APP_ENV. Na poziomie PHP-FPM można użyć osobnych pooli z różnymi plikami php.ini dla dev i prod.
W środowisku developerskim akceptowane jest display_errors = On, pełne error_reporting = E_ALL i łatwy dostęp do logów. Na produkcji te same błędy powinny trafiać wyłącznie do logów, a dostęp do nich powinien mieć tylko administrator lub system monitoringu. Ręczne „przeklikiwanie” ustawień na serwerze to proszenie się o pomyłkę – lepsza jest automatyzacja (Ansible, Docker, provisioning).
Jak bezpiecznie chronić dane w bazie w aplikacjach PHP?
Podstawą są przygotowane zapytania (prepared statements) – czy to przez PDO, czy MySQLi. Sklejanie SQL-a z wartościami $_GET/$_POST to prosty przepis na SQL Injection. Drugi filar to poprawne przechowywanie haseł: użycie password_hash() i password_verify() zamiast własnych „wynalazków” na md5/sha1.
Poza tym sensowne jest ograniczenie uprawnień konta bazy (brak DROP/ALTER, jeśli nie jest potrzebne), trzymanie danych dostępowych poza katalogiem publicznym oraz rozsądne logowanie zapytań i błędów bazy. Samo „schowanie” panelu logowania pod nietypowy URL nie rozwiązuje problemu, jeśli backend dalej przyjmuje surowe dane.
Co to znaczy, że kod PHP „działa bezpiecznie”, a nie tylko „działa”?
Kod, który „działa”, zakłada poprawne, grzeczne użycie aplikacji. Kod „działający bezpiecznie” zakłada, że użytkownik spróbuje złamać każde założenie: poda dziwne dane wejściowe, zmodyfikuje parametry żądania, prześle spreparowane cookie, wywoła endpoint nie z interfejsu, tylko z własnego skryptu.
W praktyce oznacza to m.in. walidację i filtrowanie danych wejściowych, encodowanie danych wyjściowych (np. HTML-escape pod XSS), ochronę sesji (flagi HttpOnly/Secure, session.use_strict_mode), stosowanie tokenów CSRF i regularne przeglądy miejsc, w których aplikacja „ufa” danym z zewnątrz. Sam fakt, że użytkownicy „na razie nie zgłaszają problemów”, nie jest dowodem na bezpieczeństwo, tylko na brak wykrytych incydentów.
Jak powinna wyglądać bezpieczna konfiguracja serwera Apache/Nginx dla PHP?
Najpierw trzeba ograniczyć to, co serwuje HTTP. Katalog root serwera powinien wskazywać na public/public_html, a cała reszta aplikacji (konfiguracje, vendor, backupy) ma zostać poza zasięgiem przeglądarki. Dodatkowo warto wyłączyć listowanie katalogów (Apache: Options -Indexes), aby użytkownik nie mógł podejrzeć spisu plików.
Pliki z konfiguracją (.env, config.php), pliki SQL, logi – powinny być chronione regułami serwera tak, by nigdy nie były zwracane jako statyczne zasoby. Konfiguracja HTTPS, nagłówki bezpieczeństwa (CSP, X-Frame-Options, itp.) i poprawne przekazywanie żądań do PHP-FPM to kolejne warstwy, które nie zastąpią poprawnego kodu, ale utrudnią wykorzystanie pojedynczej luki.
Czy samo wyłączenie expose_php i display_errors wystarczy, żeby aplikacja w PHP była bezpieczna?
Nie. Te ustawienia raczej „zaciemniają obraz” dla atakującego i utrudniają automatyczne skanowanie pod konkretne exploity, ale nie usuwają prawdziwych luk. Jeśli aplikacja jest podatna na SQL Injection, XSS czy RCE, to wyłączone expose_php tylko minimalnie podnosi poprzeczkę – błąd nadal da się wykorzystać.
Bezpieczna konfiguracja php.ini i serwera WWW jest pierwszą linią obrony, ale jądrem bezpieczeństwa pozostaje sam kod: sposób obsługi danych wejściowych, sesji, autoryzacji i interakcji z bazą. Traktowanie konfiguracji jako „magicznego fixu” to klasyczny błąd – pomaga, ale wyłącznie w połączeniu z przeglądem i poprawą logiki aplikacji.
Opracowano na podstawie
- PHP Manual: Security. The PHP Group – Oficjalna dokumentacja bezpieczeństwa PHP, konfiguracja i dobre praktyki.
- PHP Manual: Configuration Directives. The PHP Group – Opis kluczowych dyrektyw php.ini, m.in. display_errors, error_log, expose_php.
- OWASP Top 10: The Ten Most Critical Web Application Security Risks. OWASP Foundation (2021) – Klasyczne kategorie zagrożeń: SQLi, XSS, CSRF, RCE, wycieki danych.






