Abschnitt 11 · Lektion 6
Dateien hochladen
Ein Upload sieht aus wie ein weiteres Formularfeld und ist etwas ganz anderes: Ein Fremder legt eine Datei auf deinem Server ab. Was danach mit ihr passiert, entscheidest du, und deshalb steht zwischen dem Ankommen und dem Ablegen eine Kette von Prüfungen.
Das Formular braucht eine zusätzliche Angabe
<?php
// Zwei Formulare, ein Unterschied: Das obere hat enctype, das
// untere nicht. Waehl im Browser eine Datei von deiner Festplatte
// und schick sie einmal oben und einmal unten ab.
function e(?string $wert): string
{
return htmlspecialchars($wert ?? "", ENT_QUOTES, "UTF-8");
}
$fehlerNamen = [
UPLOAD_ERR_OK => "UPLOAD_ERR_OK (alles in Ordnung)",
UPLOAD_ERR_INI_SIZE => "UPLOAD_ERR_INI_SIZE (groesser als upload_max_filesize)",
UPLOAD_ERR_FORM_SIZE => "UPLOAD_ERR_FORM_SIZE",
UPLOAD_ERR_PARTIAL => "UPLOAD_ERR_PARTIAL (nur halb angekommen)",
UPLOAD_ERR_NO_FILE => "UPLOAD_ERR_NO_FILE (gar keine Datei)",
UPLOAD_ERR_NO_TMP_DIR => "UPLOAD_ERR_NO_TMP_DIR",
UPLOAD_ERR_CANT_WRITE => "UPLOAD_ERR_CANT_WRITE",
UPLOAD_ERR_EXTENSION => "UPLOAD_ERR_EXTENSION",
];
$anhang = $_FILES["anhang"] ?? null;
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Was ankommt</title>
<style>
td { border: 1px solid #ccc; padding: 0.2rem 0.5rem; }
</style>
</head>
<body>
<h1>Was bei einem Upload ankommt</h1>
<form method="post" action="/" enctype="multipart/form-data">
<p><strong>Mit enctype:</strong> <input type="file" name="anhang"></p>
<button type="submit">Hochladen</button>
</form>
<form method="post" action="/">
<p><strong>Ohne enctype:</strong> <input type="file" name="anhang"></p>
<button type="submit">Hochladen</button>
</form>
<h2>In $_FILES steht</h2>
<?php if ($anhang === null) { ?>
<p>nichts.</p>
<?php } else { ?>
<table>
<tr><td>name</td><td><?= e($anhang["name"]) ?></td></tr>
<tr><td>type</td><td><?= e($anhang["type"]) ?></td></tr>
<tr><td>size</td><td><?= (int) $anhang["size"] ?> Bytes</td></tr>
<tr><td>tmp_name</td><td><?= e($anhang["tmp_name"]) ?></td></tr>
<tr><td>error</td><td><?= e($fehlerNamen[$anhang["error"]] ?? "unbekannt") ?></td></tr>
</table>
<?php } ?>
<h2>In $_POST steht</h2>
<ul>
<?php foreach ($_POST as $schluessel => $wert) { ?>
<li><?= e($schluessel) ?> = <?= e(is_string($wert) ? $wert : "kein Text") ?></li>
<?php } ?>
</ul>
</body>
</html> <input type="file"> allein reicht nicht. Am <form> muss zusätzlich
enctype="multipart/form-data" stehen, sonst schickt der Browser nur den Dateinamen als ganz
gewöhnliches Textfeld mit, und der Inhalt bleibt zu Hause.
Das ist der erste Fehler, den man mit Uploads macht, und er ist besonders unangenehm, weil nichts kaputtgeht: Es kommt etwas an, es sieht sogar richtig aus, es ist nur die falsche Sache. Im Browser stehen beide Formulare untereinander. Wähl eine Datei aus und schick sie einmal oben und einmal unten ab, dann siehst du den Unterschied in den beiden Tabellen.
Was in $_FILES steht
Für Uploads gibt es ein eigenes Array, denn zu einer Datei gehört mehr als ein Wert. Unter dem
name aus dem Formular findest du fünf Angaben:
name ist der Dateiname, den der Browser mitgeschickt hat. type ist der Typ, den der Browser
behauptet. size ist die Größe in Bytes. tmp_name ist der Pfad zu einer Zwischendatei, die PHP
angelegt hat und die am Ende der Anfrage wieder verschwindet. Und error ist ein Zahlencode, der
sagt, ob überhaupt alles angekommen ist.
Von diesen fünf Angaben kommen name und type vom Absender. Sie sind Behauptungen, sonst nichts.
size, tmp_name und error stellt PHP fest.
Seit PHP 8.1 steht daneben noch ein sechster Eintrag, full_path. Bei einer einzelnen Datei steht
darin noch einmal der Dateiname; erst wenn jemand einen ganzen Ordner hochlädt, trägt er den Pfad
darin, etwa urlaub/strand.jpg. Er kommt ebenfalls vom Absender, gehört also in dieselbe Schublade
wie name.
Lässt der Besucher das Feld einfach leer, ist der Eintrag trotzdem da: error steht dann auf
UPLOAD_ERR_NO_FILE, und alles andere ist leer. Das ist kein Fehler, sondern eine Entscheidung, und
dein Programm sollte sie freundlicher behandeln als einen abgebrochenen Upload.
Die vier Prüfungen, in dieser Reihenfolge
Erstens der Fehlercode. $datei["error"] muss UPLOAD_ERR_OK sein. Alles andere heißt, dass
nichts oder nur die Hälfte angekommen ist, und dann ist jede weitere Prüfung sinnlos.
UPLOAD_ERR_NO_FILE ist dabei der häufigste Fall und meistens keine Panne: Der Besucher hat das
Feld einfach leer gelassen.
Zweitens die Größe. $datei["size"] gegen eine eigene Obergrenze. Das ist kein Misstrauen,
sondern Platz auf der Festplatte. PHP hat zwar selbst eine Grenze, upload_max_filesize, aber die
steht in der Konfiguration des Servers und ist nicht unbedingt die, die du meinst.
Drittens der Typ, und zwar aus dem Inhalt. Dazu gleich mehr.
Viertens erst das Ablegen. move_uploaded_file() schiebt die Zwischendatei an ihren Platz.
Nicht rename() und nicht copy(): Nur diese eine Funktion prüft nebenbei, dass die Quelle
wirklich ein frisch hochgeladener Zwischenspeicher ist und nicht irgendein Pfad, den jemand
untergeschoben hat.
Und ganz zum Schluss, weil es leicht untergeht: Wenn du nichts ablegst, ist die Datei am Ende der Anfrage von selbst weg. Aufräumen musst du nur, was du selbst verschoben hast.
Der Typ kommt aus der Datei, nicht aus dem Namen
<?php
// Zwei Dateien, gerade angelegt. Die eine heisst wie ein Bild und
// ist Text, die andere heisst wie Text und ist ein Bild.
$ordner = __DIR__ . "/proben";
if (!is_dir($ordner)) {
mkdir($ordner);
}
file_put_contents("$ordner/foto.png", "Das hier ist ganz normaler Text.\n");
// Ein winziges echtes PNG. Mehr als die ersten Bytes braucht
// finfo gar nicht.
$png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGA"
. "hKmMIQAAAABJRU5ErkJggg==";
file_put_contents("$ordner/bild.txt", base64_decode($png));
$kenner = finfo_open(FILEINFO_MIME_TYPE);
foreach (["foto.png", "bild.txt"] as $name) {
echo $name, "\n";
echo " Die Endung sagt: ", pathinfo($name, PATHINFO_EXTENSION), "\n";
echo " Der Inhalt sagt: ", finfo_file($kenner, "$ordner/$name"), "\n\n";
}
// Aelterer Code ruft hier finfo_close() auf. Noetig war das nie,
// und seit PHP 8.5 ist es verwarnt: Der Kenner raeumt sich selbst
// weg, sobald ihn niemand mehr braucht. $datei["type"] steht verlockend nah, und es ist der Wert, den man nie benutzt. Der Browser hat ihn
hingeschrieben, und wer die Anfrage selbst zusammenbaut, schreibt hin, was ihm passt. Das kannst du
am ersten Beispiel selbst tun, im Terminal:
curl -s -F 'anhang=@index.php;type=image/png' localhost:3000/ | grep '<td>type' lädt die
index.php des Beispiels hoch und behauptet dabei, sie sei ein PNG. In der Antwort steht danach
image/png, und die Datei ist immer noch PHP.
Die Endung im Dateinamen ist genauso wenig wert. Eine Datei heißt urlaub.jpg, weil jemand sie so
genannt hat, und mehr sagt das nicht aus.
Was wirklich drinsteht, sagt dir finfo. finfo_open(FILEINFO_MIME_TYPE) öffnet den Kenner,
finfo_file() sieht sich damit eine Datei an und gibt einen Typ zurück, etwa image/png oder
text/plain. Der Kenner liest dafür die ersten Bytes, denn die meisten Dateiformate fangen mit
einer erkennbaren Folge an.
In älterem Code steht dahinter oft noch ein finfo_close(). Nötig war das nie, und seit PHP 8.5
schreibt es sogar eine Verwarnung auf den Fehlerkanal: Der Kenner räumt sich von selbst weg. Falls
du ihn irgendwo siehst, weißt du jetzt, warum er da steht und warum er weg darf.
Und dann die entscheidende Wendung: Du prüfst nicht, ob der Typ verboten ist, sondern ob er erlaubt ist. Eine Liste der drei Typen, die deine Anwendung annimmt, ist eine Liste, die du überblickst. Eine Liste der verbotenen Typen ist eine Liste, die immer unvollständig ist.
Den Namen vergibst du selbst
<?php
// Der Name, den der Browser mitschickt, ist eine Angabe des
// Absenders wie jede andere. Drei Beispiele, was darin stehen
// kann, und was daraus wird.
$erlaubt = [
"image/jpeg" => "jpg",
"image/png" => "png",
"application/pdf" => "pdf",
];
$mitgeliefert = [
"urlaub.jpg",
"../../index.php",
"rechnung.php",
];
foreach ($mitgeliefert as $name) {
echo "Mitgeliefert: ", $name, "\n";
echo " nach basename(): ", basename($name), "\n";
echo " selbst vergeben: ", bin2hex(random_bytes(8)), ".", $erlaubt["image/png"], "\n\n";
}
echo "Die Endung kommt aus dieser Liste und nirgendwo sonst:\n";
foreach ($erlaubt as $typ => $endung) {
echo " ", $typ, " wird zu .", $endung, "\n";
} Den mitgelieferten Dateinamen zu übernehmen, ist der zweite klassische Fehler. Darin kann
../../index.php stehen und damit ein Weg aus deinem Zielverzeichnis heraus. Darin können Zeichen
stehen, die dein Dateisystem nicht mag. Und zwei Besucher mit demselben Handy laden beide
IMG_0001.jpg hoch, wobei der zweite den ersten überschreibt.
Deshalb erzeugst du den Zielnamen selbst. bin2hex(random_bytes(8)) gibt dir sechzehn zufällige
Zeichen, und die Endung nimmst du aus deiner Erlaubt-Liste, nicht aus dem, was angekommen ist. Den
ursprünglichen Namen kannst du dir daneben merken, wenn du ihn anzeigen willst, dann aber wie jede
andere Eingabe: durch e(), bevor er in eine Seite geht.
basename() schneidet immerhin den Pfad ab und ist besser als nichts. Es ist trotzdem nur die
zweitbeste Antwort, denn es löst weder das Überschreiben noch die Frage, was in dem Namen sonst noch
alles steckt.
Und wo die Dateien landen
Zum Schluss die Sache, die keine Zeile PHP ist und trotzdem in diese Lektion gehört: In deinem Zielverzeichnis darf kein PHP ausgeführt werden. Sonst reicht eine einzige Datei, die durch alle Prüfungen gerutscht ist, und jemand ruft sie einfach auf.
Am sichersten liegt der Ordner außerhalb des öffentlichen Verzeichnisses, und ein kleines Skript
liefert die Dateien aus. Das ist genau die Trennung, die du in Abschnitt 16 baust. Auf einem
klassischen Hosting-Paket geht es auch mit einer .htaccess im Zielordner, die die Ausführung dort
abschaltet.
Zum Mitnehmen
Ein Upload ist der Punkt, an dem ein Fremder eine Datei auf deinen Server legt. Das kann völlig harmlos sein, ein Profilbild eben, und es kann das Ende deiner Anwendung sein. Der Unterschied liegt in vier Prüfungen, die immer dieselben sind und immer in derselben Reihenfolge stehen.
Jetzt du
Basis Konto, kostenlosZu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.
Was in dieser Lektion steckt
-
Artikel mit 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, auf dem Server geprüft
Öffnet sich mit dem Basis Konto.