
Der vom Browser gemeldete MIME-Type und der Dateiname sind wertlos: Lies den echten Typ mit finfo, prüfe die Abmessungen, kodiere das Bild neu und erzeuge den Namen mit random_bytes. Speichere außerhalb des Webroots, in einem Verzeichnis, das nichts ausführt. Bei einem Base64-Bild dekodierst du im strikten Modus und prüfst die Größe vor dem Dekodieren.
Eine Datei von einem Besucher entgegenzunehmen gehört zu den riskantesten Funktionen einer Webanwendung. Eine Prüfung an der falschen Stelle, und der Server führt die empfangene Datei aus. Dieses Tutorial zeigt das Formular, die Verarbeitung und dann die Prüfungen, die wirklich halten – samt der Tests, die belegen, welche nicht halten. Es behandelt auch Bilder, die als Base64 ankommen, wie es bei JavaScript-Croppern üblich ist.
Das Formular
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<label for="photo">Photo</label>
<input type="file" name="photo" id="photo" accept="image/jpeg,image/png,image/gif,image/webp" required>
<button type="submit">Envoyer</button>
<p>Formats acceptés : JPEG, PNG, GIF, WebP. 5 Mo maximum.</p>
</form>Das Attribut enctype="multipart/form-data" ist Pflicht: Ohne es schickt der Browser nur den Dateinamen und $_FILES bleibt leer. Das Attribut accept und das Feld MAX_FILE_SIZE verbessern die Bedienung im Browser, Sicherheitsprüfungen sind sie nicht, denn ein Request lässt sich auch ohne das Formular zusammenbauen.
Was in $_FILES steckt
Eine über das Feld photo hochgeladene Datei landet in $_FILES['photo'], einem assoziativen Array mit fünf Einträgen. Zwei davon kommen vom Client und taugen niemals als Prüfung.
| Schlüssel | Inhalt | Quelle |
|---|---|---|
name | Ursprünglicher Dateiname, samt Endung | der Client |
type | Angekündigter MIME-Type | der Client |
size | Größe der empfangenen Datei in Bytes | der Server |
tmp_name | Pfad der temporären Datei auf dem Server | der Server |
error | Statuscode des Uploads, 0 wenn alles geklappt hat | der Server |
Die empfangene Datei wird in ein temporäres Verzeichnis geschrieben und am Ende des Skripts gelöscht. Willst du sie behalten, musst du sie mit move_uploaded_file() verschieben oder sie noch vor dem Ende des Requests gelesen haben. Die Funktion is_uploaded_file() prüft, dass der Pfad wirklich auf eine per HTTP hochgeladene Datei zeigt, und verhindert so, dass die Verarbeitung auf eine Systemdatei umgelenkt wird.
Die Prüfung, die vor nichts schützt
Das folgende Muster steht in den meisten Tutorials, auch in den früheren Fassungen dieses Artikels.
$autorises = ['jpg' => 'image/jpg', 'png' => 'image/png'];
$type = $_FILES['photo']['type']; // <- vom Browser angekündigt
if (in_array($type, $autorises)) {
move_uploaded_file($_FILES['photo']['tmp_name'], 'upload/' . $_FILES['photo']['name']);
}Zwei Lücken, gemessen unter PHP 8.5.10.
Der Wert $_FILES['photo']['type'] kommt vom Client. PHP prüft ihn nie. Ein selbst gebauter Upload darf behaupten, was er will:
type annoncé : image/png
type réel : text/x-php
getimagesize() : falseDie Datei ist ein PHP-Skript, und die Prüfung lässt sie durch.
Auch der ursprüngliche Dateiname kommt vom Client. Ihn unverändert für einen Pfad zu verwenden, öffnet gleich mehrere Türen:
'../../config.php' -> extension : php basename : config.php
'photo.php.png' -> extension : png basename : photo.php.png
'photo.png.php' -> extension : php basename : photo.png.php
'photo.pHp' -> extension : pHp basename : photo.pHpDer Fall photo.php.png ist der hinterhältigste: Die von pathinfo() gesehene Endung ist tatsächlich png, die Prüfung geht also durch, aber ein Apache mit AddHandler auf .php kann die Datei wegen der Endung in der Mitte ausführen. Der Fall photo.pHp erinnert daran, dass der Vergleich der Endung zwischen Groß- und Kleinschreibung unterscheidet.
Die Validierung, die hält
Drei Grundsätze: Der Typ wird im Inhalt gelesen, nicht in der Ankündigung, der Name wird neu erzeugt, nie übernommen, das Bild wird neu kodiert.
<?php
declare(strict_types=1);
const TYPES_AUTORISES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
/**
* @return array{0: bool, 1: string} Erfolg und Dateiname, oder Fehlschlag und Grund
*/
function validerImage(
string $chemin,
int $maxOctets = 5_242_880,
int $maxPixels = 50_000_000,
): array {
if (!is_file($chemin)) {
return [false, 'fichier absent'];
}
$taille = filesize($chemin);
if ($taille === false || $taille === 0) {
return [false, 'fichier vide'];
}
if ($taille > $maxOctets) {
return [false, 'trop volumineux'];
}
// Der echte Typ, aus den Bytes der Datei gelesen
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($chemin);
if (!isset(TYPES_AUTORISES[$mime])) {
return [false, "type refusé : {$mime}"];
}
$infos = @getimagesize($chemin);
if ($infos === false) {
return [false, 'image illisible'];
}
[$largeur, $hauteur] = $infos;
if ($largeur < 1 || $hauteur < 1) {
return [false, "dimensions invalides ({$largeur}x{$hauteur})"];
}
if ($largeur * $hauteur > $maxPixels) {
return [false, 'bombe de décompression'];
}
// Neukodierung: einzige verlässliche Abwehr gegen Polyglott-Dateien
$image = @imagecreatefromstring(file_get_contents($chemin));
if ($image === false) {
return [false, 'décodage impossible'];
}
// Unvorhersehbarer Name, und direktes Schreiben ins Speicherverzeichnis.
// tempnam() und danach rename() würde fehlschlagen, wenn /tmp und der Speicherort
// auf zwei verschiedenen Dateisystemen liegen, was der Regelfall
// im Container ist.
$nom = bin2hex(random_bytes(16)) . '.' . TYPES_AUTORISES[$mime];
$destination = __DIR__ . '/../stockage/' . $nom;
$ok = match (TYPES_AUTORISES[$mime]) {
'jpg' => imagejpeg($image, $destination, 85),
'png' => imagepng($image, $destination),
'gif' => imagegif($image, $destination),
'webp' => imagewebp($image, $destination, 85),
};
if (!$ok) {
return [false, 'réencodage impossible'];
}
return [true, $nom];
}Die Verarbeitung des Requests, samt Fehlercodes:
<?php
declare(strict_types=1);
require __DIR__ . '/src/upload.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Méthode non autorisée');
}
if (!isset($_FILES['photo'])) {
http_response_code(400);
exit('Aucun fichier reçu');
}
$erreur = $_FILES['photo']['error'];
if ($erreur !== UPLOAD_ERR_OK) {
$message = match ($erreur) {
UPLOAD_ERR_INI_SIZE => 'Fichier plus grand que upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'Fichier plus grand que MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => 'Envoi interrompu',
UPLOAD_ERR_NO_FILE => 'Aucun fichier sélectionné',
UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire absent sur le serveur',
UPLOAD_ERR_CANT_WRITE => 'Écriture impossible sur le disque',
UPLOAD_ERR_EXTENSION => 'Envoi bloqué par une extension PHP',
default => 'Erreur inconnue',
};
http_response_code(400);
exit($message);
}
// Stellt sicher, dass die Datei wirklich aus einem HTTP-Upload stammt
if (!is_uploaded_file($_FILES['photo']['tmp_name'])) {
http_response_code(400);
exit('Envoi invalide');
}
[$succes, $resultat] = validerImage($_FILES['photo']['tmp_name']);
if (!$succes) {
http_response_code(422);
exit('Fichier refusé : ' . $resultat);
}
echo 'Enregistré sous ', htmlspecialchars($resultat, ENT_QUOTES, 'UTF-8');Was jede Prüfung abfängt
Vier Dateien durch validerImage() geschickt, unter PHP 8.5.10 mit GD 2.0.35:
| Hochgeladene Datei | Ergebnis | Ausgelöste Prüfung |
|---|---|---|
PHP-Skript, umbenannt in .png | abgelehnt | echter Typ: text/x-php |
| Polyglott: GIF-Header + PHP-Code | abgelehnt | Abmessungen (0×0 oder absurd) |
| Echtes PNG 120×120 | angenommen | — |
| Echtes PNG mit angehängtem PHP | angenommen | neu kodiert, die Payload verschwindet |
Die zweite Zeile verdient einen Moment. Eine Datei, die mit GIF89a beginnt und von PHP-Code gefolgt wird, erkennt finfo als image/gif. Die Erkennungsbibliothek vertraut der Signatur der ersten Bytes, und diese Signatur ist korrekt. Nur die Dimensionsprüfungen weisen sie ab: Je nach den Bytes nach dem Header liest getimagesize() 0×0 oder absurde Maße, und dann verwirft die Pixel-Obergrenze die Datei. Eine Validierung, die bei finfo endet, lässt sie durch.
Die vierte Zeile ist der häufigste Fall: ein völlig gültiges Bild, an das eine Payload angehängt wurde. Es wird angenommen, und das ist richtig so, denn es ist ein echtes Bild – gespeichert wird aber das Ergebnis der Neukodierung, nicht das Original. Die Payload überlebt das nicht.
Die Speicherung zählt so viel wie die Validierung
Keine Validierung ersetzt ein Speicherverzeichnis, das nichts ausführt. Es muss außerhalb des Webroots liegen und über ein Skript ausgeliefert werden, das den richtigen Content-Type setzt.
<IfModule mod_php.c>
php_flag engine off
</IfModule>
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
Header set X-Content-Type-Options "nosniff"location ^~ /stockage/ {
location ~ \.php$ { return 403; }
add_header X-Content-Type-Options "nosniff" always;
}Der Header X-Content-Type-Options: nosniff hindert den Browser daran, den Typ einer Datei zu raten und in einem Bild gefundenes HTML oder JavaScript auszuführen.
Die Grenzen aus php.ini entscheiden vor deinem Code
foreach (['file_uploads','upload_max_filesize','post_max_size','max_file_uploads','memory_limit'] as $d) {
printf("%-20s %s\n", $d, ini_get($d));
}Eine Datei, die größer ist als post_max_size, kommt mit vollständig leerem $_FILES und $_POST an: Die Prüfung isset($_FILES['photo']) schlägt fehl und die Fehlermeldung führt in die Irre. Diesen Fall musst du gesondert erkennen:
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && empty($_FILES)
&& (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
http_response_code(413);
exit('Envoi plus grand que post_max_size (' . ini_get('post_max_size') . ')');
}Dieser Block hat eine Bedingung, die wir erst beim Testen entdeckt haben: Er funktioniert nur, wenn display_errors abgeschaltet ist. PHP gibt seine eigene Warnung aus, bevor auch nur eine Zeile des Skripts läuft, und sobald diese Warnung erscheint, sind die Header längst raus.
display_errors = On -> HTTP 200 + « Warning: PHP Request Startup: POST Content-Length
of 8388808 bytes exceeds the limit of 6291456 bytes »
puis « Cannot set response code - headers already sent »
display_errors = Off -> HTTP 413 « Envoi plus grand que post_max_size (6M) »In der Produktion gehört display_errors ohnehin auf Off. Ein Grund mehr.
Mit upload_max_filesize = 5M und post_max_size = 6M kommt eine Datei von 5,5 MB an der ersten Schranke vorbei und wird von der zweiten gestoppt, mit UPLOAD_ERR_INI_SIZE und Status 400. post_max_size muss größer bleiben als upload_max_filesize, sonst hat der zweite Wert keine Wirkung.
Ein Bild als Base64 entgegennehmen
JavaScript-Cropper und Aufnahmen aus einem <canvas> schicken das Bild oft als Zeichenkette data:image/png;base64,… statt als Datei. Am Prinzip der Validierung ändert das nichts, es kommen aber zwei Fallstricke dazu.
Der erste: base64_decode() ohne sein zweites Argument nimmt alles an und liefert zufällige Bytes statt eines Fehlers.
var_dump(base64_decode('ceci n est pas du base64 !!')); // string(9) "q..." – beliebige Bytes
var_dump(base64_decode('ceci n est pas du base64 !!', true)); // bool(false)Der zweite: Eine Base64-Zeichenkette ist rund ein Drittel größer als die Daten, die sie enthält. Die Größe vor dem Dekodieren zu prüfen, erspart dir, 40 MB zu belegen, um danach eine Datei von 30 abzulehnen.
<?php
declare(strict_types=1);
/**
* @return array{0: bool, 1: string}
*/
function enregistrerImageBase64(
string $entree,
string $dossier,
int $maxOctets = 2_097_152,
): array {
if (!preg_match('#^data:image/(jpeg|png|gif|webp);base64,#', $entree, $m)) {
return [false, 'préfixe data: absent ou type non autorisé'];
}
$encode = substr($entree, strlen($m[0]));
// Base64 wiegt 4/3 der Daten: wir filtern vor dem Dekodieren
if (strlen($encode) > (int) ceil($maxOctets * 4 / 3)) {
return [false, 'trop volumineux (avant décodage)'];
}
$binaire = base64_decode($encode, true); // strikt
if ($binaire === false) {
return [false, 'base64 invalide'];
}
if (strlen($binaire) > $maxOctets) {
return [false, 'trop volumineux'];
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($binaire);
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => null,
};
if ($extension === null) {
return [false, "type réel refusé : {$mime}"];
}
$infos = @getimagesizefromstring($binaire);
if ($infos === false || $infos[0] < 1 || $infos[1] < 1) {
return [false, 'image illisible'];
}
$nom = bin2hex(random_bytes(16)) . '.' . $extension;
if (file_put_contents(rtrim($dossier, '/') . '/' . $nom, $binaire, LOCK_EX) === false) {
return [false, 'écriture impossible'];
}
return [true, $nom];
}Gemessene Ergebnisse für fünf Eingaben:
| Eingabe | Ergebnis | Grund |
|---|---|---|
| Gültiges PNG 1×1 | angenommen | — |
PHP-Code, angekündigt als image/png | abgelehnt | echter Typ: text/x-php |
SVG mit onload | abgelehnt | Typ nicht erlaubt |
| Beliebige Zeichenkette | abgelehnt | Präfix data: fehlt |
| Beschädigtes Base64 | abgelehnt | striktes Dekodieren schlägt fehl |
SVG gehört bewusst ausgeschlossen. Es ist ein XML-Format, das JavaScript und externe Referenzen enthalten kann. Musst du es akzeptieren, dann nur durch einen dafür gebauten Sanitizer gereinigt und niemals von derselben Domain ausgeliefert wie die Anwendung.
Zwei Fallstricke im Code, den man überall findet
Das naive Zerlegen einer data:-Zeichenkette setzt voraus, dass die Eingabe wohlgeformt ist:
$parties = explode(';base64,', $img);
$aux = explode('image/', $parties[0]);
$type = $aux[1]; // Undefined array key 1, wenn die Eingabe beliebig ist
$binaire = base64_decode($parties[1]); // Undefined array key 1, danach Deprecation auf nullUnd ein mit uniqid() erzeugter Dateiname ist nicht unvorhersehbar: Die Funktion leitet sich von der Uhr ab, aufeinanderfolgende Werte lassen sich erraten.
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96bin2hex(random_bytes(16)) nutzt den kryptografischen Generator des Systems. Das ist die einzige richtige Form, wenn der Name nicht erratbar sein darf.
Das Wichtigste in Kürze
- Typ und Name, die der Client ankündigt, sind wertlos: den Typ aus dem Inhalt lesen, den Namen neu erzeugen.
finfoallein reicht nicht – ein GIF-Polyglott täuscht es. Prüfung der Abmessungen und Neukodierung dazunehmen.- Außerhalb des Webroots speichern, in einem Verzeichnis, das nichts ausführt.
- Bei Base64: strikt dekodieren, die Größe vor dem Dekodieren prüfen, SVG ablehnen.
Um den Namen der gespeicherten Datei ans Frontend zurückzugeben, ohne eine Lücke aufzureißen, siehe Variablen von PHP an JavaScript übergeben. Eine empfangene Datei von mehreren Dutzend Megabyte verarbeitest du nicht am Stück: siehe große Dateien mit PHP lesen. Nach dem Speichern verhindert eine 303-Weiterleitung, dass das Formular beim Neuladen erneut abgeschickt wird. Und die hier gezeigte Validierungsklasse folgt den Prinzipien der OOP in PHP.
Weiter geht es mit Bildbearbeitung mit GD und Imagick und dem Hub Webentwicklung.
Häufige Fehler
image/png für ein PHP-Skript. Den echten Typ liest du mit finfo, nie aus $_FILES['x']['type'].photo.php.png hat für pathinfo() tatsächlich die Endung png, doch ein falsch konfigurierter Apache kann die Datei wegen der Endung in der Mitte ausführen. Der Name wird neu erzeugt.GIF89a beginnt und danach PHP-Code enthält, wird als image/gif erkannt. Nur die Prüfung der Abmessungen weist sie ab.bin2hex(random_bytes(16)).true nimmt die Funktion alles an und liefert beliebige Bytes statt false.$_FILES und $_POST kommen leer an. Und der Code, der das erkennt, funktioniert nur, wenn display_errors auf Off steht: sonst gibt PHP seine Warnung vor deinem Skript aus und die Header sind schon unterwegs.

