Sichere Bild-Uploads in PHP
Über einen Upload-Endpoint können Fremde Dateien auf Ihren Server schreiben. In PHP ist der schlimmste Fall ein ganz bestimmter: eine Datei, die der Webserver später als PHP ausführt. Diese Seite geht die Prüfungen in rte/upload.php des Beispiels durch, zeigt, was jede davon verhindert und was nicht, und endet mit der wichtigsten Regel: Nichts in uploads/ darf jemals ausgeführt werden.
Vertrauen Sie nicht dem, was der Browser angibt
$_FILES['file']['name'] und $_FILES['file']['type'] stammen beide aus dem Request. Ein Client kann jeden beliebigen Namen und Typ senden. Wir haben eine Datei mit dem Inhalt <?php echo "hello"; ?> als holiday.png mit type=image/png gesendet. Eine Prüfung von type hätte sie angenommen. Das Beispiel ignoriert type vollständig und sieht sich stattdessen die Bytes an:
// The name decides the extension; the bytes must agree with it.
// $file['type'] is whatever the browser (or attacker) sent, so it is ignored.
$ext = rte_extension((string)($file['name'] ?? ''));
if (!in_array($ext, RTE_IMAGE_EXTENSIONS, true)) {
throw new RteHttpError(400, 'Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.');
}
$types = ['jpg' => IMAGETYPE_JPEG, 'jpeg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, 'gif' => IMAGETYPE_GIF, 'webp' => IMAGETYPE_WEBP];
// Signature first, then getimagesize() (core PHP, no GD needed) parses the header.
// A bare signature followed by junk still parses, but as 0x0, so require real dimensions.
$info = @getimagesize($file['tmp_name']);
if (!rte_signature_matches($file['tmp_name'], $ext) || $info === false
|| $info[2] !== $types[$ext] || $info[0] < 1 || $info[1] < 1) {
throw new RteHttpError(400, 'The file is not a valid .' . $ext . ' image.');
}Die Dateiendung muss eine von fünf sein, und die ersten Bytes der Datei müssen zu ihr passen:
// The first bytes of the file must match its extension.
function rte_signature_matches(string $file, string $ext): bool
{
$handle = fopen($file, 'rb');
if ($handle === false) {
return false;
}
$head = (string)fread($handle, 12);
fclose($handle);
return match ($ext) {
'jpg', 'jpeg' => str_starts_with($head, "\xFF\xD8\xFF"),
'png' => str_starts_with($head, "\x89PNG\r\n\x1A\n"),
'gif' => str_starts_with($head, 'GIF87a') || str_starts_with($head, 'GIF89a'),
'webp' => str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP',
default => false,
};
}Warum getimagesize() und die Prüfung auf 0 x 0
getimagesize() liest den Header des Bildes und meldet Typ und Abmessungen. Die Funktion gehört zum PHP-Kern und funktioniert daher ohne GD oder fileinfo. Für sich allein ist sie leicht zufriedenzustellen: Eine PNG-Signatur, gefolgt von 3 MB Nullen, bestand getimagesize() als image/png mit einer Breite und Höhe von 0. Deshalb verlangt das Beispiel zusätzlich, dass beide Abmessungen mindestens 1 sind.
Keine der beiden Prüfungen macht eine Datei harmlos. Wir haben <?php echo "EXECUTED"; ?> an ein echtes PNG mit 1×1 Pixel angehängt. Es hat eine gültige Signatur, gültige Abmessungen, und der PHP-Code steckt trotzdem darin. Das Beispiel hat es gespeichert, wie es jede Bildprüfung tun würde. Dafür, dass das keine Rolle spielt, sorgen die nächsten beiden Regeln.
Generierte Namen
Den gespeicherten Namen bildet der Server: der ursprüngliche Name in Kleinbuchstaben, reduziert auf a-z, 0-9 und -, dann acht zufällige Hex-Ziffern, dann die geprüfte Dateiendung. Aus Red Square.png wird etwa red-square-abc60cfb.png. Der Server schreibt also nie einen Namen mit einem zweiten Punkt (shell.php.png), überschreibt nie eine vorhandene Datei und behält nie einen Namen, den der Client gewählt hat. Eine Datei namens shell.php wird schon vorher abgelehnt: 400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}.

holiday.jpg, vom Server abgelehnt. Der Dialog zeigt seine eigene Meldung; der Grund des Servers steht im Body der Antwort.Nie etwas in uploads/ ausführen
Diese Regel macht es ungefährlich, wenn bei den anderen Prüfungen etwas schiefgeht. Konfigurieren Sie den Webserver so, dass er Dateien aus uploads/ immer nur als Bytes senden kann. Unter Apache kopieren Sie uploads.htaccess aus dem Beispiel nach uploads/.htaccess, oder, besser, Sie setzen dieselben Zeilen im Vhost innerhalb von <Directory>:
# 1. Only image files can be requested. Everything else (x.php, x.phtml, .htaccess) -> 403,
# whatever handler (mod_php, PHP-FPM, CGI) the server uses for .php.
Require all denied
<FilesMatch "(?i)\.(jpe?g|png|gif|webp)$">
Require all granted
</FilesMatch>
# 2. Even an allowed name must not reach PHP. "AddHandler ... .php" also matches
# "x.php.png" (mod_mime looks at every extension), so drop those mappings here.
RemoveHandler .php .phtml .phar .php3 .php4 .php5 .php7 .php8 .pht .phps
RemoveType .php .phtml .phar .php3 .php4 .php5 .php7 .php8 .pht .phps
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Options -Indexes -ExecCGI -Includes
# 3. Browsers must not guess a different type from the bytes.
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
</IfModule>Die Zeile mit RemoveHandler deckt eine klassische Apache-Falle ab. AddHandler application/x-httpd-php .php passt auf jede Dateiendung in einem Namen, sodass photo.php.png als PHP laufen würde. Unter nginx ist das Präfix ^~ der entscheidende Teil:
# ^~ : once a URL starts with /uploads/, nginx skips regex locations, so a
# "location ~ \.php$" block can never run an uploaded file.
location ^~ /uploads/ {
deny all;
location ~* \.(jpe?g|png|gif|webp)$ {
allow all;
add_header X-Content-Type-Options nosniff always;
}
}
# Nothing else is PHP-executable (rte/files-lib.php, router.php, demo-login.php).
location ~ \.php$ { return 404; }Ohne ^~ gewinnt ein normales location ~ \.php$ { fastcgi_pass ...; } an anderer Stelle der Konfiguration gegen ein einfaches location /uploads/, und /uploads/x.php wird ausgeführt. Behalten Sie außerdem security.limit_extensions = .php im PHP-FPM-Pool bei, damit FPM sich weigert, photo.png auszuführen, selbst wenn eine andere Regel die Datei weiterreicht.
Der eingebaute Entwicklungsserver von PHP hat dieselbe Falle in eigener Form. Gibt ein Router-Skript false zurück, behandelt der Server die Datei selbst und führt .php-Dateien aus. Der Router des Beispiels liefert /uploads/ selbst aus, nur Bilder. Wir haben es geprüft: /uploads/probe.php ergab ein 404, und /uploads/probe.php.png kam als image/png-Bytes zurück, ohne ausgeführt zu werden.
Wir haben das Setup mit dem eingebauten Server unter Windows mit PHP 8.2 getestet. Die Apache- und nginx-Regeln oben wurden auf diesem Rechner nicht ausgeführt, weil dort keiner der beiden Server installiert ist. Testen Sie sie auf Ihrem eigenen Server: Legen Sie eine test.php in uploads/ ab und fordern Sie sie an. Sie sollten 403 oder 404 erhalten, niemals ihre Ausgabe.
Ordner und Pfade
Das Feld folder ist ein virtueller Pfad, der Teil für Teil geprüft und dann mit realpath() innerhalb des Stammordners aufgelöst wird. Damit werden .., absolute Pfade, Laufwerksbuchstaben, Backslashes sowie Symlinks oder Junctions, die nach außen zeigen, abgelehnt. Die Anleitung zu Ordnern enthält den Code und das, was wir mit einer Windows-Junction beobachtet haben.
Was das Beispiel Ihnen überlässt
Das Beispiel meldet niemanden an, und seine beiden POST-Routen haben kein CSRF-Token. rte-files.js sendet einen einfachen XMLHttpRequest ohne zusätzliche Header. Ein Framework, dessen CSRF-Middleware bei jedem POST läuft, etwa die web-Routen von Laravel, würde ihn daher ablehnen, sofern Sie diese Routen nicht ausnehmen oder das Token selbst ergänzen. Prüfen Sie den Benutzer vor rte_root_for_request(). Die Anleitung zu Ordnern pro Benutzer zeigt, wo das hingehört.
Testen
Während das Beispielprojekt zu dieser Anleitung auf Port 5201 läuft:
printf '<?php echo "hello"; ?>' > holiday.png
curl -F "file=@holiday.png;type=image/png" http://localhost:5201/rte/upload
{"error":"The file is not a valid .png image."}
curl -F "file=@holiday.png;filename=shell.php" http://localhost:5201/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}Beide Antworten sind HTTP 400. Der gemeinsame Vertragstest sendet dieselben Arten von Dateien (ein Skript namens .png, eine .svg, eine .php, einen Ordner mit Path Traversal) und erwartet, dass jede abgelehnt wird: node shared/contract-test.mjs http://localhost:5201 endet mit ALL 35 CHECKS PASSED.
Dieselbe Anleitung für andere Plattformen
JavaScript · Next.js · ASP.NET Core · Razor Pages · MVC 5 · Web Forms