Sichere Datei-Uploads in PHP validieren
AI generated
<?php
8.4
PHP · Security · Datei-Upload · Validierung
Sichere Datei-Uploads in PHP validieren
vom MIME-Type bis zur Storage-Strategie

Ein Upload-Formular ist eine der direktesten Angriffsflächen einer PHP-Anwendung, weil ein Angreifer hier eigenen Inhalt kontrolliert auf den Server bringt. Wer sich allein auf die Dateiendung oder den vom Client gesendeten Content-Type verlässt, öffnet die Tür für Remote Code Execution, Path Traversal und eingeschleuste Skriptdateien, die als harmlose Bilder getarnt sind.

18 Min. Lesezeit MIME-Type · finfo · Sanitizing · Storage PHP 8.x · Framework-unabhängig

1. Warum Datei-Uploads eine besonders kritische Angriffsfläche sind

Datei-Uploads unterscheiden sich von anderen Nutzereingaben dadurch, dass der Angreifer nicht nur Text, sondern beliebigen Binärinhalt kontrolliert an den Server sendet. Ohne konsequente Validierung von Datei-Uploads kann eine als bild.jpg benannte Datei tatsächlich eine PHP-Webshell enthalten, die nach dem Hochladen direkt aufgerufen und ausgeführt wird, sofern der Zielordner vom Webserver interpretiert wird. Diese Kombination aus Kontrolle über den Inhalt und potenzieller Ausführung macht Datei-Uploads zu einem der folgenreichsten Einfallstore in PHP-Anwendungen.

Die Absicherung von Datei-Uploads ist kein einzelner Check, sondern eine Kette von Validierungsschritten, bei der jeder einzelne Schritt für sich genommen umgehbar ist, aber die Kombination aller Schritte eine belastbare Verteidigung ergibt. Dateiendung, angegebener Content-Type, tatsächlicher Dateiinhalt, Dateigröße, Zielverzeichnis und Ausführungsrechte müssen gemeinsam betrachtet werden, weil ein Angreifer gezielt nach der schwächsten Stelle in dieser Kette sucht.

2. Grundabsicherung über php.ini-Direktiven

Bevor überhaupt eigener Code für Datei-Uploads geschrieben wird, sollten die relevanten php.ini-Direktiven konfiguriert sein. upload_max_filesize und post_max_size begrenzen die maximale Dateigröße bereits auf PHP-Ebene, bevor das Upload-Skript überhaupt läuft. file_uploads sollte in Anwendungen ohne Upload-Funktion komplett auf Off gesetzt werden, um die Angriffsfläche von vornherein zu reduzieren.

Diese Direktiven ersetzen keine Anwendungslogik-Validierung für Datei-Uploads, verhindern aber bereits auf einer tieferen Ebene, dass übergroße Uploads den Speicher oder die Festplatte des Servers erschöpfen, bevor die eigentliche Validierungslogik überhaupt zum Zug kommt. Wichtig: post_max_size muss größer als upload_max_filesize sein, da POST-Daten neben der Datei auch weitere Formularfelder enthalten.


<?php

declare(strict_types=1);

// php.ini settings relevant for upload hardening (excerpt)
// upload_max_filesize = 5M
// post_max_size = 6M
// file_uploads = On   (Off entirely if the application never accepts uploads)
// max_file_uploads = 5

final class UploadLimits
{
    public const MAX_BYTES = 5 * 1024 * 1024; // 5 MB application-level limit

    public static function assertWithinLimit(int $size): void
    {
        if ($size > self::MAX_BYTES) {
            throw new RuntimeException('File exceeds the allowed upload size');
        }
    }
}

3. Echte MIME-Type-Prüfung statt Client-Angaben zu vertrauen

Der klassische Anfängerfehler bei Datei-Uploads ist, $_FILES['datei']['type'] zu vertrauen. Dieser Wert stammt vom Client, wird über den Content-Type-Header des Browsers gesetzt und lässt sich mit jedem beliebigen HTTP-Client trivial fälschen. Eine ausführbare PHP-Datei, die vorgibt, image/jpeg zu sein, wird von dieser oberflächlichen Prüfung anstandslos akzeptiert. Für belastbare Datei-Uploads-Validierung muss der tatsächliche Dateiinhalt geprüft werden, nicht die Behauptung des Clients.

Die PHP-Erweiterung fileinfo mit der Funktion finfo_file() untersucht die sogenannten Magic Bytes am Dateianfang und ermittelt so den echten MIME-Type unabhängig von Dateiendung oder Client-Header. Für Datei-Uploads, die auf bestimmte Dateitypen wie Bilder beschränkt sein sollen, ist eine Allowlist erlaubter MIME-Types gegen das finfo-Ergebnis der zuverlässige Standard, kombiniert mit einer zusätzlichen Prüfung über getimagesize() bei Bildern, die zusätzlich echte Bilddimensionen verifiziert.


<?php

declare(strict_types=1);

final class UploadValidator
{
    /** @var list<string> */
    private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

    /**
     * Verify the actual file content, never trust the client-provided type.
     */
    public function assertAllowedMimeType(string $tmpPath): string
    {
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $detectedType = finfo_file($finfo, $tmpPath);
        finfo_close($finfo);

        if ($detectedType === false || !in_array($detectedType, self::ALLOWED_MIME_TYPES, true)) {
            throw new RuntimeException(sprintf('Rejected file with detected type: %s', $detectedType ?: 'unknown'));
        }

        // Extra verification for images — must have real, parseable dimensions
        $imageInfo = @getimagesize($tmpPath);
        if ($imageInfo === false) {
            throw new RuntimeException('File claims to be an image but has no valid image structure');
        }

        return $detectedType;
    }
}

4. Dateinamen sicher sanitisieren und Path Traversal verhindern

Der ursprüngliche Dateiname aus $_FILES['datei']['name'] stammt vollständig vom Client und darf niemals unverändert für den Zielpfad von Datei-Uploads verwendet werden. Ein Dateiname wie ../../etc/cron.d/evil nutzt Path Traversal, um die Datei außerhalb des vorgesehenen Upload-Verzeichnisses zu platzieren. Selbst ohne bösartige Pfadangaben können Sonderzeichen, Null-Bytes oder extrem lange Namen zu Problemen im Dateisystem oder in nachgelagerter Verarbeitung führen.

Die sichere Lösung für Datei-Uploads ist, den ursprünglichen Dateinamen komplett zu verwerfen und stattdessen einen neu generierten, zufälligen Namen zu vergeben, etwa auf Basis eines UUID oder eines kryptografisch sicheren Zufallswerts. Der ursprüngliche Dateiname kann getrennt davon, rein zur Anzeige, escaped in der Datenbank gespeichert werden, hat aber niemals direkten Einfluss auf den tatsächlichen Speicherpfad im Dateisystem.


<?php

declare(strict_types=1);

final class SafeFilenameGenerator
{
    /** @var array<string, string> */
    private const EXTENSION_BY_MIME = [
        'image/jpeg' => 'jpg',
        'image/png'  => 'png',
        'image/webp' => 'webp',
    ];

    /**
     * Generate a random, collision-resistant filename — never derived from user input.
     */
    public function generate(string $mimeType): string
    {
        $extension = self::EXTENSION_BY_MIME[$mimeType]
            ?? throw new RuntimeException('Unsupported MIME type for filename generation');

        return sprintf('%s.%s', bin2hex(random_bytes(16)), $extension);
    }
}

$validator = new UploadValidator();
$detectedType = $validator->assertAllowedMimeType($_FILES['datei']['tmp_name']);

$generator = new SafeFilenameGenerator();
$storedFilename = $generator->generate($detectedType);
// Original client filename is stored separately, only for display, always escaped

5. Dateigröße und Ressourcenverbrauch begrenzen

Neben der Konfiguration in php.ini sollte jede Anwendung mit Datei-Uploads eine eigene, fachlich begründete Größengrenze auf Anwendungsebene durchsetzen. Ein Profilbild-Upload braucht keine 50-Megabyte-Grenze, selbst wenn der Server technisch größere Dateien akzeptieren würde. Eine zu großzügige Grenze bei Datei-Uploads öffnet die Tür für Denial-of-Service-Angriffe, bei denen viele große Uploads parallel den verfügbaren Speicherplatz oder Arbeitsspeicher erschöpfen.

Zusätzlich zur reinen Dateigröße lohnt sich bei Datei-Uploads, die serverseitig weiterverarbeitet werden, etwa durch Bildskalierung, eine Begrenzung der Pixelanzahl. Eine kleine Datei mit extrem hoher Auflösung, ein sogenannter Dekompressions-Angriff, kann beim Entpacken erheblich mehr Arbeitsspeicher belegen als die ursprüngliche Dateigröße vermuten lässt, was ebenfalls zu einem Ressourcenerschöpfungs-Angriff führen kann.

6. Speicherort-Strategie: außerhalb des Webroots ausliefern

Der wirksamste einzelne Schutz gegen Remote Code Execution bei Datei-Uploads ist, hochgeladene Dateien außerhalb des vom Webserver direkt erreichbaren Verzeichnisses zu speichern. Liegt eine hochgeladene Datei unterhalb von document_root, kann selbst eine erfolgreich eingeschleuste PHP-Datei nicht direkt per URL aufgerufen werden, weil der Webserver den Pfad gar nicht kennt. Der Zugriff erfolgt stattdessen ausschließlich über ein PHP-Skript, das die Datei nach eigener Autorisierungsprüfung ausliest und mit korrektem Content-Type-Header ausliefert.

Ist eine Ablage innerhalb des Webroots aus praktischen Gründen unvermeidbar, etwa bei bestehenden CDN-Integrationen, sollte der Ausführungsmechanismus des Webservers für dieses Verzeichnis explizit deaktiviert werden. Für Apache bedeutet das eine .htaccess-Regel, die PHP-Ausführung im Upload-Verzeichnis unterbindet, für Nginx eine entsprechende location-Direktive. Diese serverseitige Härtung ergänzt die Anwendungslogik von Datei-Uploads um eine zusätzliche, unabhängige Verteidigungsebene.


<?php

declare(strict_types=1);

final class SecureUploadStorage
{
    // Deliberately OUTSIDE the public webroot — not directly URL-accessible
    public function __construct(private readonly string $storageDir = '/var/app-storage/uploads')
    {
    }

    public function store(string $tmpPath, string $filename): string
    {
        $destination = rtrim($this->storageDir, '/') . '/' . $filename;

        if (!move_uploaded_file($tmpPath, $destination)) {
            throw new RuntimeException('Failed to move uploaded file to storage');
        }

        // Files under this directory are never executed, only read by this script
        chmod($destination, 0644);
        return $destination;
    }
}

// download.php — the only path through which stored files are ever exposed
function serveUpload(string $storedFilename, string $mimeType): void
{
    header('Content-Type: ' . $mimeType);
    header('X-Content-Type-Options: nosniff');
    header('Content-Disposition: inline; filename="download"');
    readfile('/var/app-storage/uploads/' . basename($storedFilename));
}

7. Bild-Reencoding gegen versteckten Schadcode

Selbst ein Bild, das MIME-Type-Prüfung und getimagesize()-Validierung besteht, kann in seinen Metadaten oder unbenutzten Bilddaten-Segmenten versteckten Schadcode enthalten, etwa eine PHP-Payload in einem EXIF-Kommentarfeld. Für hochsensible Datei-Uploads-Szenarien ist Reencoding die zuverlässigste Zusatzmaßnahme: Das hochgeladene Bild wird mit einer Bildbibliothek wie GD oder Imagick vollständig neu dekodiert und in ein frisches Bild derselben Auflösung geschrieben, wodurch jeglicher Inhalt außerhalb der reinen Bilddaten verworfen wird.

Der Nachteil dieses Ansatzes bei Datei-Uploads: Reencoding kostet CPU-Zeit und kann bei animierten Formaten wie GIF oder bei verlustfreien Formaten zu unerwünschten Qualitätsverlusten führen, die vorab kommuniziert werden sollten. Für Anwendungen mit hohem Sicherheitsanspruch, etwa öffentlich zugänglichen Upload-Formularen ohne vorherige Nutzer-Authentifizierung, überwiegt der Sicherheitsgewinn diesen Nachteil in der Regel deutlich.

8. Typische Fehler bei der Upload-Validierung

Der häufigste Fehler ist, sich ausschließlich auf die Dateiendung zu verlassen und diese über eine simple Denylist wie .php zu blockieren. Alternative, vom Webserver ebenfalls ausführbare Endungen wie .phtml, .php5 oder .phar werden dabei häufig übersehen, was Datei-Uploads-Schutz auf Basis von Denylists grundsätzlich unzuverlässig macht. Eine Allowlist erlaubter Endungen in Kombination mit echter MIME-Type-Prüfung ist der robustere Ansatz.

Ein zweiter Fehler ist, den ursprünglichen Client-Dateinamen direkt für den Speicherpfad zu verwenden, wodurch Path Traversal und Namenskollisionen möglich werden. Ein dritter, oft übersehener Fehler betrifft doppelte Dateiendungen wie bild.jpg.php, die je nach Webserver-Konfiguration weiterhin als PHP interpretiert werden, obwohl eine naive Prüfung nur die letzte Endung betrachtet und .jpg.php als vermeintlich harmlos durchgehen lässt.

9. Validierungsschritte im direkten Vergleich

Die folgende Übersicht zeigt, welche Validierungsschritte bei Datei-Uploads häufig unzureichend umgesetzt werden und was stattdessen die robuste Alternative ist.

Prüfschritt Unsicher Robuster Ansatz Grund
Dateityp-Erkennung $_FILES['type'] finfo_file() Magic Bytes Client-Angabe ist trivial fälschbar
Dateiendung Denylist (.php blockieren) Allowlist erlaubter Endungen .phtml, .phar etc. werden nicht vergessen
Zieldateiname Client-Dateiname übernehmen Zufällig generierter Name Kein Path Traversal, keine Kollisionen
Speicherort Direkt im Webroot Außerhalb, Auslieferung per Skript Hochgeladener Code nicht direkt aufrufbar
Bildinhalt Nur MIME-Type prüfen Reencoding mit GD/Imagick Entfernt versteckten Code in Metadaten

Keiner dieser Schritte ist für sich genommen ausreichend, aber gemeinsam bilden sie eine mehrschichtige Verteidigung, bei der ein Angreifer gleich mehrere unabhängige Hürden gleichzeitig überwinden müsste, um Datei-Uploads für einen erfolgreichen Angriff zu missbrauchen.

Mironsoft

PHP-Security-Audits, Upload-Härtung und Storage-Architektur

Upload-Formulare gegen Remote Code Execution absichern?

Wir prüfen bestehende Upload-Funktionen auf MIME-Type-Validierung, Dateinamen-Handling und Speicherort-Strategie und rüsten eine mehrschichtige, produktionsreife Absicherung nach, ohne bestehende Formulare für Nutzer spürbar zu verändern.

Upload-Audit

Prüfung aller Upload-Formulare auf Validierungslücken und Ausführungsrisiken

Storage-Architektur

Speicherung außerhalb des Webroots mit sicherer Auslieferung per Skript

Reencoding-Pipeline

Automatisches Bild-Reencoding gegen versteckten Schadcode in Metadaten

10. Zusammenfassung

Sichere Datei-Uploads in PHP entstehen aus dem Zusammenspiel mehrerer unabhängiger Schutzmaßnahmen: echte MIME-Type-Prüfung über finfo_file() statt Client-Angaben, zufällig generierte Dateinamen statt unveränderter Client-Namen, Speicherung außerhalb des Webroots mit Auslieferung über ein kontrolliertes Skript, und bei Bildern zusätzlich Reencoding gegen versteckten Schadcode in Metadaten. Keine dieser Maßnahmen ersetzt die andere, jede schließt eine spezifische Umgehungsmöglichkeit.

Der entscheidende gedankliche Schritt bei Datei-Uploads ist, niemals einer einzelnen vom Client stammenden Angabe zu vertrauen, weder Dateiendung noch Content-Type noch ursprünglicher Dateiname. Jede dieser Informationen dient bestenfalls als Hinweis, die tatsächliche Validierung muss immer auf dem echten Dateiinhalt basieren, der serverseitig unabhängig geprüft wird.

Sichere Datei-Uploads in PHP validieren — Das Wichtigste auf einen Blick

Echte MIME-Type-Prüfung

finfo_file() statt $_FILES['type'], kombiniert mit getimagesize() bei Bildern.

Zufälliger Dateiname

Client-Dateiname niemals für den Speicherpfad übernehmen, immer neu generieren.

Speicherort außerhalb Webroot

Auslieferung ausschließlich über ein PHP-Skript mit eigener Autorisierungsprüfung.

Reencoding bei Bildern

GD oder Imagick zur vollständigen Neudekodierung, entfernt versteckte Payloads.

11. FAQ: Sichere Datei-Uploads in PHP validieren

1Reicht die Dateiendung als Prüfung?
Nein, Endungen sind beliebig setzbar und sagen nichts über den echten Inhalt aus.
2Warum ist $_FILES['type'] unsicher?
Wird vom Client gesetzt und ist trivial fälschbar, finfo_file() prüft die echten Datei-Bytes.
3Wie verhindere ich Path Traversal?
Nie den Client-Dateinamen für den Speicherpfad nutzen, immer zufällig neu generieren.
4Warum außerhalb des Webroots speichern?
Verhindert direkten URL-Aufruf eingeschleuster Dateien, Auslieferung erfolgt kontrolliert per Skript.
5Was bringt Bild-Reencoding?
Entfernt versteckten Code in Metadaten, den eine reine MIME-Type-Prüfung übersehen würde.
6Wie groß darf die Größenbegrenzung sein?
Fachlich begründet und so klein wie sinnvoll, unabhängig von den technischen php.ini-Grenzen.
7Was ist ein Dekompressions-Angriff?
Kleine Datei mit hoher Auflösung belegt beim Entpacken viel mehr Speicher als die Dateigröße vermuten lässt.
8Reicht eine Denylist für Endungen?
Nein, vergisst oft Varianten wie .phtml oder doppelte Endungen. Allowlist ist zuverlässiger.
9move_uploaded_file() statt rename()?
Ja, prüft zusätzlich echte HTTP-Upload-Herkunft und schützt vor manipulierten Dateipfaden.
10Wie teste ich meine Validierung?
Gezielte Testfälle: PHP-Datei als .jpg, doppelte Endung .jpg.php, gefälschter Content-Type, Path-Traversal-Namen.