PHP/Tutorials/File Upload

Aus SELFHTML-Wiki
< PHP‎ | Tutorials
Wechseln zu: Navigation, Suche

In vielen Situationen erscheint es notwendig, mittels HTML-Formular auch Dateien an den Server zu übertragen, also sogenannte Uploads durchzuführen. Hierbei kann es sich um Bilder zum Text, um mitzuliefernde Textdateien, Office-Dokumente oder auch HTML-Seiten handeln.

Oft wird dabei leider übersehen, dass man sich durch die Bereitstellung eines File-Uploads eine Sicherheitslücke in seinen Server reißen kann.

Dieser Artikel soll daher nicht zeigen, wie man mal eben schnell einen Dateiupload in seine Webseiten einbaut, indem man sich kurzsichtig ein „Uploadscript“ aus dubiosen Quellen zusammenkopiert. Hier soll stattdessen Schritt für Schritt die Funktionsweise und die Sicherheitsmaßnahmen erläutert werden.

Des Weiteren geht es in diesem Artikel nicht nur um das Hochladen der Dateien, sondern auch um ihre sichere Bereitstellung, denn häufig sollen hochgeladene Dateien auch einem Publikum zugänglich gemacht werden. In diesem Artikel sei als Beispiel ein einfacher Bilder-Upload gewählt, bei dem Nutzer Bilder hochladen und der Öffentlichkeit bereitstellen können. Auf potenzielle rechtliche Probleme in Bezug auf Urheberrecht sei hier nur verwiesen, behandelt werden sie hier allerdings nicht. Als Anregung für eine Beschäftigung mit möglichen Problemen können die Nutzungsbedingungen für den Bilderupload im SELFHTML-Forum aufgegriffen werden.

Beachten Sie: Nehmen Sie die in diesem Artikel ausgesprochenen Warnungen ernst und nehmen Sie sich vor allem Zeit zum Verstehen. Ein irgendwie funktionierender Dateiupload muss noch längst nicht sicher realisiert sein!

Rahmenbedingungen

  • Als Dateiformate soll der Datei-Upload lediglich JPG, PNG und GIF akzeptieren. Weitere Dateitypen lassen sich hinzufügen.
  • Die maximale Upload-Größe soll bei 2 MB liegen.
  • Die Dateien sollen aufgelistet werden und öffentlich zugreifbar sein.

Komponenten des Datei-Uploads

Ein einfacher, minimaler Datei-Upload besteht aus einem HTML-Formular und des zum Verarbeiten der hochgeladenen Datei zwingend erforderlichen Programms auf dem Server, das in unserem Fall in PHP geschrieben sein soll.

Das HTML-Formular an sich ist unspektakulär, Details sind im dazugehörigen Artikel zu finden:

HTML-Formular für den Dateiupload
<form method="post" enctype="multipart/form-data">
   <!-- Angabe einer maximalen Dateigröße in Bytes (hier: 2 MB). -->
  <input type="hidden" name="MAX_FILE_SIZE" value="2000000">
  <label>Wählen Sie ein Bild (*.jpg, *.png oder *.gif) zum Hochladen aus.
    <input name="datei" type="file" accept="image/gif,image/jpeg,image/png"> 
  </label>  
  <button>Datei hochladen</button>
</form>

Das PHP-Script, das die POST-Anforderung mit den hochgeladenen Dateien verarbeiten soll, wird wie üblich im action-Attribut des Formulars angegeben. Fehlt es, so werden die Formulardaten und damit die Dateien an die Adresse des Dokuments selbst übermittelt. Ein Affenformular im klassischen Sinne lässt sich übrigens nicht realisieren, weil sich Dateieingabefelder aus Gründen der Sicherheit nicht vorbelegen lassen. Falls also außer der hochzuladenden Datei noch mehr Eingaben erforderlich sind und diese einer strengen Validierung genügen müssen, so sollte man darüber nachdenken, die einzelnen Schritte aufzuteilen, um Dateiuploads von den übrigen Eingaben des Nutzers zu trennen. Die Zusammenführung der Daten steuern Sie über eine PHP Session.

Damit Dateien übertragen werden können, müssen die Formulardaten in einem passenden Format zum Server geschickt werden. Dazu dient das enctype (encoding type) Attribut des form-Elements. Gibt man es nicht an, werden die gesendeten Daten wie in einer URL codiert und vom File-Upload Mechanismus von PHP nicht verstanden. Dieser erwartet das multipart/form-data Format. Der Dateiinhalt ist andernfalls zwar dennoch verfügbar, aber nicht als Dateiupload, sondern so, als wäre er Inhalt eines Eingabefeldes gewesen.

Über das accept-Attribut des input-Elements-Attribut geben Sie dem Browser des Nutzers einen Hinweis darauf, welche Dateien Ihr Datei-Upload entgegen nimmt. Man notiert eine durch Komma getrennte Liste der vom Server akzeptierten Dateitypen. Als Dateityp kann ein konkreter MIME-Type verwendet werden, eine Dateiendung wie beispielsweise .gif (der Punkt muss enthalten sein!) oder eine Medienkategorie. Zulässige Kategorien sind audio/*, image/* und video/*, für Ton-, Bild- oder Filmdateien. Für unser Beispiel möchten wir nur Bilder in den Formaten GIF, JPEG und PNG verarbeiten, eine Angabe wie image/* wäre zu unspezifisch.

Ein Browser bieten dann üblicherweise auch nur die akzeptierten Dateien zum Upload an, was einen Komfortgewinn für den Nutzer darstellt. Sie gewinnen damit aber keinerlei Sicherheit, da der Browser diese Angabe ignorieren oder der Nutzer diese Angabe manipulieren kann. Die Datei muss folglich unbedingt auch noch serverseitig überprüft werden.

Das versteckte Feld mit dem Namen MAX_FILE_SIZE findet man oft in Beispielen, aber es ist nur in Grenzsituationen wirklich sinnvoll. Es muss vor einem type="file"-input notiert werden, damit PHP es vor der Datei erhält. Die PHP Dokumentation erläutert, dass Benutzer damit unnötiges Uploadvolumen sparen könnten, vor allem die Zeit für unötige Uploads. Mehr als ein freundlicher Hinweis ist das jedoch nicht, und das Versprechen wird auch nur zu einem Bruchteil eingelöst. Der Browser sendet die Bytes der Datei auf jeden Fall zum Server, PHP hört bei Überschreiten der MAX_FILE_SIZE lediglich damit auf, die Daten in eine temporäre Datei zu schreiben. Statt dessen findet man im Error-Eintrag zu dieser Datei UPLOAD_ERR_FORM_SIZE vor. Der POST-Anfragedatenstrom muss von PHP aber dennoch vollständig gelesen werden, weil er noch weitere Dateien oder Inhalte enthalten könnte. Gespart wird also nur die Zeit, die der Server braucht, um die Datei in einen temporären Ordner zu schreiben. Eine Maßnahme gegen DoS-Attacken ist das aber auch nicht, denn ein Angreifer würde MAX_FILE_SIZE nicht setzen. Die wirklich relevanten Grenzen für Dateiuploads finden sich aber in der php-Konfiguration, worauf wir später eingehen.}} }}

Wie PHP Dateiuploads behandelt

Die Grundlagen der Formularverarbeitung mittels PHP werden bereits an anderer Stelle behandelt. Wurde eine Datei ausgewählt und wird das Formular abgesendet, werden die Dateien mit der HTTP-Anfragemethode POST übertragen und von PHP in einem temporären Ordner abgelegt. Erst wenn dies geschehen ist, wird das aufgerufene PHP-Skript ausgeführt und kann aus dem von PHP angelegten superglobalen Array $_FILES Informationen zu der oder den hochgeladenen Dateien auslesen sowie die Dateien prüfen und ggf. aus dem temporären Ordner an eine andere Stelle verschieben.

Für den Einstieg lassen Sie sich den Inhalt des Arrays mit folgendem Code-Schnipsel ausgeben:

Inhalt des $_FILES-Array nach erfolgtem Upload
<?php

if (!empty($_FILES)) {
    echo "<pre>\r\n";
    echo htmlspecialchars(print_r($_FILES, 1));
    echo "</pre>\r\n";
}

?>
Die Ausgabe könnte wie folgt aussehen:
Array
(
    [datei] => Array
        (
            [name] => beispiel.png
            [type] => image/png
            [tmp_name] => /tmp/phpbUMoU1
            [error] => 0
            [size] => 2393
        )

)

Das $_FILES-Array ist zweistufig. Auf der ersten Stufe finden Sie einen Eintrag pro name der Uploadfelder im Formular. Für unser Upload-Feld mit name="datei" finden Sie unter $_FILES["datei"] ein weiteres Array, in dem sich fünf Einträge mit Informationen zur hochgeladenen Datei finden:

$_FILES['datei']['name'] 
der Name, den die Datei im Dateisystem des Nutzers hat. Dieser Name kann echt sein, aber auch frei erfunden oder bösartig konstruiert. Verwenden Sie ihn als beschreibenden Text, benennen Sie auf Ihrem Server keine Dateien damit.
$_FILES['datei']['type'] 
der MIME-Typ, den der Browser für diese Datei mitteilt. Diese Angabe muss nicht aus dem Dateiinhalt abgeleitet sein, sie kann allein auf der Namenserweiterung basieren. Sie kann auch frei erfunden oder vorsätzlich verfälscht sein. Eine automatische Überprüfung, ob der MIME Typ zum Dateiinhalt passt, ist in PHP nicht vorgesehen. Das müssen Sie selbst programmieren.
$_FILES['datei']['tmp_name'] 
der Name der temporären Datei, unter dem PHP die hochgeladene Datei zwischengespeichert hat.
$_FILES['datei']['size'] 
die Größe der hochgeladenen Datei, in Bytes.
$_FILES['datei']['error'] 
ein möglicher Fehlercode. Bei einem erfolgreichen Upload finden Sie hier 0. Das Thema Fehlerbehandlung wird später noch genauer behandelt.

Theoretisch verfügen Sie nun über alle nötigen Angaben, um die Datei von ihrem temporären Ort mittels der Funktion move_uploaded_file an ihren finalen Ort zu verschieben. Die Funktion erwartet zwei Parameter: Den temporären Namen der Datei und das gewünschte Ziel. Man könnte in die Versuchung kommen, sich am Ziel zu wähnen und als Ziel den ursprünglichen Namen der Datei mit Angabe des Pfads zu einem Upload-Ordner verwenden. Es würde funktionieren - bis zu dem Moment, wo Ihnen jemand einen bösartig konstruierten Dateinamen unterschiebt und Ihren Server kapert.

Achtung!

Die Angaben zu name und type stammen vom Computer des Nutzers. Sie können von einem Browser nach bestem Wissen erzeugt worden - und trotzdem noch falsch oder gefährlich sein. Möglicherweise lässt das Dateisystem des Absenders Zeichen zu, die das Dateisystem Ihres Servers nicht verträgt. Möglicherweise schickt der Nutzer die Dateien 'Bild.jpg' und 'bild.jpg' von einem Linux-System, und Ihr Server läuft unter Windows, wo Dateinamen nicht case-sensitive sind? Möglicherweise heißt eine Datei 'bild.jpg' und der Browser schickt den MIME-Typ image/jpeg, aber der Inhalt ist ein GIF. Oder ein Textdokument. Und das sind nur die ärgerlichen Pannen. Schlimmer wird es, wenn Vorsatz hinzukommt und eine POST-Anforderung nicht von einem Browser kommt, sondern aggressiv konstruiert wurde. Was ist, wenn Sie als Dateiname '/etc/hosts' erhalten? Oder '.htaccess'? Sie dürfen den Dateinamen ausschließlich als eine Beschreibung des Dateiinhaltes auffassen und keinesfalls als Dateiname auf Ihrem Server verwenden. Und den MIME-Typ müssen Sie entweder an Hand des Dateiinhalts überprüfen, oder ihn durch geeignete Programmierung ganz eigenständig ermitteln.

Relevante Konfigurationsoptionen

Da Dateien über die HTTP-Methode POST hochgeladen werden, muss die PHP-Konfiguration entsprechend große POST-Anfragen zulassen. Dafür sind die folgenden Direktiven der php.ini Datei relevant:

Allgemeine Direktiven zu POST-Anfragen

enable-post-data-reading
Dieser Schalter ist eine Grundsatz-Einstellung. Er steht standardmäßig auf 1, und muss diesen Wert haben, damit PHP die $_POST und $_FILES Arrays überhaupt bestückt.
post_max_size
Legt fest, wie groß eine POST-Anfrage sein darf und muss für Datei-Uploads so eingestellt werden, dass die erwarteten Dateigrößen hochgeladen werden können. Der Standardwert ist 8M (also 8MiB). Ist eine POST-Anfrage zu groß, bricht PHP die Verarbeitung ab und man erhält weder in $_POST noch in $_FILES irgendwelche Daten.
Beachten Sie bitte auch, dass Ihr Webserver ebenfalls ein Limit für die Größe von POST-Anfragen festlegt. Wenn eine POST-Anfrage mit dem HTTP Statuscode 413 Payload too large beantwortet wird, sollten Sie zuerst die Einstellungen des Webservers überprüfen. Beim Apache ist das die LimitRequestBody-Direktive, beim IIS die Featureeinstellungen der Anforderungsfilterung.

Direktiven speziell für Uploads

file_uploads
Diese Direktive muss auf 1 stehen, damit das Hochladen von Dateien grundsätzlich freigeschaltet ist. Ihr Standardwert ist bereits 1.
upload_max_filesize
Mit dieser Direktive legen Sie serverseitig fest, wie groß eine einzelne hochgeladene Datei höchstens sein darf. Überschreitet eine Datei diese Größe, finden Sie im $_FILES-Array für diesen Upload den error-Wert UPLOAD_ERR_INI_SIZE. Der Standardwert für diese Direktive ist 2M (2MiB).
upload-tmp-dir
Hiermit legen Sie das Verzeichnis fest, in dem PHP die hochgeladenen Dateien ablegt, während es die POST-Anfrage entgegennimmt. Es ist eine temporäre Ablage, Sie müssen die Dateien aktiv von hier abholen, sonst löscht PHP sie wieder. PHP überträgt zunächst sämtliche Upload-Dateien in diesen Ordner, bevor es Ihr Script startet.
Standardmäßig ist diese Direktive leer, und PHP verwendet dann den Temp-Ordner des Systems. Auf unixoiden Systemen ist das /tmp, unter Windows kann es C:\Windows\Temp sein oder auch ein Ordner im Benutzerprofil des Prozesses, in dem der Webserver läuft. Der Pfad ist Teil des ['name']-Eintrags zur Datei.
Bei Shared-Webhosting kann es vorkommen, dass sich alle Nutzer eine Instanz des PHP-Interpreters teilen und daher jeder Nutzer die hochgeladenen Dateien anderer Nutzer aus dem /tmp-Verzeichnis lesen und manipulieren kann. Gute Webhoster spendieren jedem Nutzer einen eigenen Unix-Nutzeraccount und einen eigenen PHP-Interpreter, der mit dessen Rechten läuft, sodass für die im /tmp-Verzeichnis abgelegten Dateien die Dateirechte passend gesetzt sind (Zugriff nur durch den Eigentümer) und das für das /tmp-Verzeichnis gesetzte Sticky-Bit dafür sorgt, dass nur der Ersteller der Datei diese löschen und verschieben darf. Betreiben Sie hingegen ihren eigenen Webserver, haben Sie volle Kontrolle über die passende Rechtevergabe.

Das PHP Handbuch enthält ebenfalls eine Übersicht zu diesen Direktiven.

Sie haben gesehen, dass es vier Stellen gibt, die die Größe eines Uploads limitieren. Jedes einzelne Limit muss eingehalten werden, damit der Upload gelingt. Werden das Webserver-Limit oder post_max_size überschritten, wird entweder Ihr Script gar nicht erst gestartet, oder Sie erhalten zumindest keinerlei Daten. Eine Verletzung von upload_max_filesize oder MAX_FILE_SIZE verhindert nur, dass Sie die betroffene Datei erhalten.

Angriffsvektoren

Ohne weitere Überprüfungen würde man einem potenziellen Angreifer Tür und Tor öffnen: Er kann mangels Dateityp-Überprüfung alle Arten von Dateien hochladen und mit einem beliebigen Namen versehen. Und nicht nur das: Er kann die Datei in einem beliebigen Verzeichnis ablegen, da der vom Browser übermittelte Dateiname natürlich auch Pfadtrennzeichen wie / enthalten kann und ein Angreifer mittels .. aus dem vorgegebenen Upload-Verzeichnis ausbrechen kann, siehe Directory Traversal. Aber auch der Dateiname selbst kann ein Problem darstellen: Einige Plattformen unterscheiden Groß- und Kleinschreibung, in einigen Systemen haben verschiedene Zeichen oder Dateinamen eine Sonderbedeutung, beispielsweise kennzeichnet ein Punkt am Beginn eines Dateinamens unter unixoiden Systemen eine Datei als versteckte Datei, während eine Datei mit dem Namen PRN unter Windows aus historischen Gründen nicht zulässig ist[1]. Zudem unterscheiden unixoide Systeme üblicherweise bei der Klein- und Großschreibung von Dateinamen, während Windows dies nicht tut – DATEI.TXT und datei.txt sind unter einem unixoiden System zwei verschiedene Dateien, unter Windows hingegen ein und die selbe Datei.

In Bezug auf den Dateiinhalt gilt es, ausführbare Dateien oder Skripte nicht zuzulassen. Dies ist allerdings nicht immer so leicht, wie es klingt. XML-Dokumente können Gefahren mit sich bringen[2][3] oder GIF-Dateien JavaScript enthalten[4]. Auch SVG kann innerhalb eines script-Elements JavaScript enthalten und müsste, falls ein Upload gestattet wäre, erst bereinigt werden, zudem sollten XSS mit strikten Content-Security-Policy-Regeln verhindert werden. Zudem lassen sich verschiedene Dateiformate nur bedingt in sicher und gefährlich einteilen, meist spielt auch die konkrete Konfiguration des (Web-)Servers eine bedeutende Rolle. Lässt man bestimmte Dateitypen zum Upload zu, sollte dem eine gründliche Recherche und Risikobewertung zugrunde liegen.

Verarbeiten der hochgeladenen Datei auf dem Server

Nachdem der Nutzer das Formular abgesandt und der PHP-Interpreter die hochgeladenen Daten entgegen genommen und als temporäre Datei abgespeichert hat, wird das eigentliche Skript aufgerufen, das wir zur Verarbeitung des Dateiuploads vorgesehen haben.

Überprüfung des Dateityps

Bei der Überprüfung kann natürlich nach dem Blacklist-Ansatz gearbeitet werden, um oben geschilderte schädliche bzw. potenziell problematische Dateien und Dateinamen abzufangen oder zu behandeln. Dies ist allerdings komplex, daher fehleranfällig und bedarf stetiger Überprüfung auf neue Angriffsvektoren. Daher ist diesem Ansatz ein Whitelist-Ansatz vorzuziehen, bei dem explizit nur bestimmte, definierte Dateitypen zulässig sind und diese auch sinnvoll überprüft werden können.

In diesem Tutorial machen wir es uns leicht und damit auch möglichst sicher, indem jede hochgeladene Datei einen neuen, zufällig generierten Namen erhält. So wird eine komplizierte und damit fehleranfällige Überprüfung des Dateinamens umgangen. Den ursprünglichen Dateinamen können Sie jedoch optional zusammen mit anderen Metadaten wie dem Datum des Hochladens allerdings in einer Datenbank speichern. Außerdem beschränken wir uns auf bestimmte Formate, die sich gut erkennen lassen. Hierzu greifen wir auf die Funktion mime_content_type(), die als Parameter der Pfad zu einer zu überprüfenden Datei erwartet und den ermittelten MIME-Typ zurückgibt. Diesen gleichen wir mit einer Liste erlaubter MIME-Typen ab und ermitteln hierbei auch die Dateiendung.

Die Funktionsweise von mime_content_type lehnt sich an das file-Kommando unter Unix an: Anhand bestimmter charakteristischen Muster im Dateiinhalt wird der MIME-Typ erkannt. Eine GIF-Datei beginnt beispielsweise mit Bytes, die als ASCII-kodierter Text „GIF87a“ oder „GIF89a“ ergeben. Nicht alle sogenannten „Magischen Bytes“, anhand derer man eine Datei erkennen kann, sind so beschreibend gewählt wie im Falle von GIF. Das braucht uns auch nicht interessieren, denn die Zuordnung der Magischen Bytes zu MIME-Typen wird anhand einer Datenbank vorgenommen, die PHP mitliefert – das bedeutet auch, dass diese Datenbank sich von Installation zu Installation in Details unterscheiden kann. Bei gängigen Formaten ist dies allerdings kein Problem.

Nicht alle Dateien lassen sich auf diese Art eindeutig erkennen. Beispielsweise können Dateien, deren Format auf anderen Formaten wie dem Format für Archivdateien ZIP oder der Auszeichnungssprachen wie XML aufbaut, fälschlicherweise als solche und nicht als das gewünschte Format erkannt werden. Die Dateiformate vieler Textverarbeitungsprogramme basieren häufig auf einem ZIP-Archiv, das die eingebundenen Dateien und den Text enthält. Sollen diese Dateien hochgeladen werden können und dafür korrekt erkannt werden, muss zusätzlich zum MIME-Type (z. B. application/zip) der übermittelte Dateinamen die zugehörige Dateiendung (z. B. .docx oder .odt) geprüft werden.

Prüfung, ob der Dateityp erlaubt ist
$allowed_files = [
  'image/jpeg' => 'jpg',
  'image/gif' => 'gif',
  'image/png' => 'png'
];

// Es wurde eine Datei hochgeladen und dabei sind keine Fehler aufgetreten
if(!empty($_FILES) && $_FILES['datei']['error'] == UPLOAD_ERR_OK) {
  $type = mime_content_type($_FILES['datei']['tmp_name']);
  $new_filename = '';
  
  // Größe überprüfen
  if(isset($allowed_files[$type])) {
    if(filesize($_FILES['datei']['tmp_name']) <= 2000000) {
      // Dateityp und -größe sind ok, die Datei kann also an ihr endgültiges Ziel verschoben werden
    } else {
      // Fehlermeldung wegen der zu großen Datei anzeigen
    }
  } else {
    // Fehlermeldung wegen des nicht erlaubten Dateityps anzeigen
  }
}

Sollen weitere Dateien hochladbar sein – beispielsweise PDFs und das neue Bildformat WebP – und sind diese Formate „ungefährlich“, so müssen MIME-Type und Dateiendung im Array und im HTML-Formular ergänzt werden:

MIME-Types hinzufügen
<?php
$allowed_files = [
  'image/jpeg' => 'jpg',
  'image/gif' => 'gif',
  'image/png' => 'png',
  'image/webp' => 'webp',
  'application/pdf' => 'pdf'
];
<input name="datei"
       type="file"
       accept="image/gif,image/jpeg,image/png,image/webp,application/pdf">

Anmerkung zu getimagesize()

Häufig wird empfohlen, die Funktion getimagesize() zu verwenden. Diese erwartet allerdings als Eingabewerte eine Bilddatei und kann bei Nicht-Bilddateien unzuverlässige Werte liefern, sie ist folglich ungeeignet, um festzustellen, ob eine beliebige Datei eine Bilddatei ist. Die PHP-Doku verweist dazu auf die Fileinfo-Erweiterung, deren Funktion mime_content_type() in diesem Artikel zur Identifizierung des Dateityps benutzt wird.

Täuschung von getimagesize() und mime_content_type()
<?php
// Dateiname: fakegif.php

$filename = tempnam('.', 'fakeimage_');

// Datei erstellen, die mit den magischen Bytes „GIF89a“ einer GIF-Datei beginnt
// und diese mittels random_bytes() mit zufälligen Daten füllen
file_put_contents($filename, 'GIF89a'.random_bytes(100));

echo "\nMIME-Type:", mime_content_type($filename), "\n\n";

print_r(getimagesize($filename));

echo "\n";

// temporär angelegte Datei löschen:
unlink($filename);
Obwohl die zufällig generierte Datei (höchstwahrscheinlich) keine valide GIF-Datei ist, erkennen sowohl getimagesize() als auch mime_content_type() sie als solche. Das zum Bestimmen der Abmessungen einer Bilddatei bestimmte getimagesize() bietet folglich kein Mehr an Sicherheit gegenüber mime_content_type(), im Gegenteil suggeriert es eher eine falsche Sicherheit.
$ php fakegif.php 

MIME-Type:image/gif

Array
(
    [0] => 31683
    [1] => 58957
    [2] => 1
    [3] => width="31683" height="58957"
    [bits] => 6
    [channels] => 3
    [mime] => image/gif
)

Einen Dateinamen wählen

Zur Speicherung der hochgeladenen Datei benutzen wir nicht den übergebenen Namen, sondern einen eigenen. Zweckmäßigerweise benutzen wir hierzu die Funktion uniqid(), zudem überprüfen wir vor dem Verschieben, ob bereits eine Datei mit dem gewählten Namen existiert und generieren in dem Fall einen neuen Namen. Da es nicht möglich ist, nach der Überprüfung den gewählten Dateipfad beim Verschieben mittels move_uploaded_file() mittels flock() zu sperren, um zu verhindern, dass genau im gleichen Moment ein anderer Dateiupload unter dem gleichen Namen gespeichert werden kann. Grundsätzlich kann man sich zu diesem Zweck eine eigene, spezialisierte Funktion schreiben[5], muss dann allerdings berücksichtigen, dass move_uploaded_file() beim Quellpfad von den Beschränkungen durch safe mode und open_basedir ausgenommen ist.

Für unseren Anwendungszweck nehmen wir an, dass der Zeitraum zwischen dem Prüfen und dem Verschieben der Datei so klein ist, dass hier mit extrem hoher Wahrscheinlichkeit kein Problem auftritt. Zudem lassen wir den temporären Namen in die Generierung des Dateinamens einfließen, sodass die Kollisionswahrscheinlichkeit weiter sinkt, denn der PHP-Interpreter wird nicht den gleichen temporären Namen für zwei verschiedene Uploadvorgänge vergeben.

Erzeugen eines neuen Dateinamens
$upload_dir = '/pfad/zum/upload-verzeichnis/';
do {
  $new_filename = md5(uniqid($_FILES['datei']['tmp_name'], true)).'.'.$allowed_files[$type];
} while (file_exists($new_filename));
move_uploaded_file($_FILES['datei']['tmp_name'], $upload_dir.$new_filename);
Die Hash-Funktion MD5 wird zusätzlich benutzt, um einen einheitlich langen String zu erzeugen. Dass sie für kryptografische Zwecke nicht mehr benutzt werden sollte, spielt hier keine Rolle.
Die Variable $upload_dir bestimmt das Zielverzeichnis für den Dateiupload, dessen Wahl im nächsten Abschnitt erläutert wird. $allowed_files[$type] stammt aus dem Abschnitt zur Bestimmung des Dateityps.

Speicherort der hochgeladenen Dateien

Grundsätzlich können hochgeladene Dateien an zwei Stellen im Dateisystem abgelegt werden: Im Document-Root oder außerhalb. Ist dieser beispielsweise /var/www/html, wäre /var/www/uploads außerhalb des Document-Roots, /var/www/html/uploads innerhalb.

Häufig wird empfohlen, hochgeladene Dateien außerhalb des Document-Roots abzulegen und dann mit einem PHP-Skript auszuliefern. Dies kann insofern sinnvoll sein, wenn eine Vielzahl verschiedener Dateitypen hochgeladen werden darf und bei den Dateinamen mit einem Blacklist- statt dem in diesem Artikel verwendeten Whitelist-Ansatz gearbeitet wird. – In diesem Fall kann es problematisch sein, da bei problematischer Konfiguration des Webservers hochgeladene Dateien vom PHP-Interpreter ausgeführt werden – steckt dann im Kommentar in einer hochgeladenen GIF-Datei PHP-Code, wird dieser ausgeführt. Außerdem muss sichergestellt werden, dass keine Konfigurationsdateien wie .htaccess durch den Webserver in diesem Verzeichnis interpretiert und / oder ausgeführt werden. Außerdem werden je nach Konfiguration auch Dateien mit dem Dateinamen beispiel.php.html durch den verbreiteten Apache Webserver als PHP-Datei behandelt.

Grundsätzlich sollten für das Ablageverzeichnis der Datei sämtliche Interpreter ausgeschaltet werden.

Durch das strikte Zulassen ausschließlich bekannter Dateitypen sowie die strikte Neuvergabe von Dateinamen wird ein Großteil dieser Risiken aus dem Weg gegangen – prüfen Sie dennoch, wie Ihr Webserver konfiguriert ist – wird eine mit der Dateiendung einer Bilddatei wie .jpg oder .png versehene PHP-Datei ausgeführt? Außerdem sollten Sie überprüfen, ob der Webserver die hochgeladenen Dateien mit dem korrekten Content-Type ausliefert und zudem der HTTP-Header X-Content-Type-Options: nosniff gesetzt werden[6].

Falls Sie nur angemeldeten Nutzern bestimmte Dateien zugänglich machen wollen, dürfen Sie sich keinesfalls auf die Nicht-Erratbarkeit der Dateinamen als Zugriffsschutz verlassen. In diesem Fall dürfen die hochgeladenen Dateien nicht direkt zugreifbar sein und müssen von einem Skript ausgeliefert werden, das die Berechtigung des Nutzers prüft. In jedem Fall muss das Skript aber prüfen, ob die auszuliefernde Datei aus dem Upload-Verzeichnis und nicht aus irgendeinem anderen Pfad stammt – sonst können beliebige Dateien abgerufen werden. Diese Klasse von Sicherheitslücken heißt Directory Traversal.

Datei mit einem PHP-Skript ausliefern
if(!empty($_GET['file'])) {
  $file = realpath($upload_dir.$_GET['file']);
  if(strpos($file, realpath($upload_dir)) === 0) {
    header('X-Content-Type-Options: nosniff');
    header('Content-Type: ' . mime_content_type($file));
    header('Content-Length: ' . filesize($file));
    readfile($file);
    die();
  } else {
    http_response_code(404);
    die('Datei nicht gefunden. Prüfen Sie bitte die Adresse.');
  }
}
Die Überprüfung, ob die Datei im Upload-Verzeichnis ($upload_dir) liegt, erfolgt dadurch, dass mittels realpath() der real aufgerufene Pfad aufgelöst wird (/test/../bla/file.txt würde beispielsweise zu /bla/file.txt) und anschließend mittels strpos() überprüft wird, ob der resultierende Pfad ein Unterverzeichnis des Upload-Verzeichnisses ist. Beachten Sie, dass das Ergebnis von strpos() mittels === typsicher verglichen werden muss, weil die Funktion sowohl den boolschen Wert FALSE als auch den Integer 0 zurückliefern kann.

Fehlerbehandlung

Weiter oben wurde bereits durch Überprüfen des Werts error im $_FILES-Array festgestellt, ob die Datei erfolgreich hochgeladen werden konnte. Jetzt gilt es, eine aussagekräftige Fehlermeldung für den Nutzer auszugeben, falls dies nicht der Fall war. Im Beispiel wird nur für die Fehlercodes eine spezifischere Meldung als „Upload der Datei fehlgeschlagen.“ ausgegeben, bei denen die Ursache beim Nutzer liegt. Falls der Fehler-Code UPLOAD_ERR_EXTENSION – eine PHP-Erweiterung hat den Upload gestoppt – auftritt, ist es für die Fehlersuche beim Entwickeln zwar hilfreich (daher sollte sich mittels error_log() eine entsprechende Meldung ins Error-Log schreiben lassen, dem reinen Nutzer dieses Formulars würde eine detaillierte Meldung in diesem Fall nichts bringen, er würde durch sie höchstens verwirrt.

Generieren einer Fehlermeldung
$messages = [];

switch ($_FILES['datei']['error']) {
  case UPLOAD_ERR_OK:
  // Datei wurde erfoglreich hochgeladen, keine Meldung erzeugen
  break;
  
  case UPLOAD_ERR_INI_SIZE:
  $messages[] = 'Die Datei überschreitet die maximal erlaubte Größe.';
  break;

  case UPLOAD_ERR_FORM_SIZE:
  $messages[] = 'Die Datei überschreitet die maximal erlaubte Größe.';
  break;

  case UPLOAD_ERR_NO_FILE:
  $messages[] = 'Es wurde keine Datei ausgewählt.';
  break;

  // Weitere Fehlertypen definiert: https://www.php.net/manual/de/features.file-upload.errors.php
  // Allerdings bringt eine Unterscheidung dem Nutzer hier nichts...
  default:
  $messages[] = 'Upload der Datei fehlgeschlagen.';
  break;
An passender Stelle im Quellcode kann dem Nutzer entweder der Erfolg des Hochladens oder der Grund des Fehlschlagens gemeldet werden:
<?php
if(empty($messages)) {
  echo 'Datei wurde erfolgreich hochladen.';
} else {
?>
<ul>
<?php
  foreach($messages as $message) {
<?php
<li><?= htmlspecialchars($message) ?></li>
?>
  }
?>
</ul>
<?php
}

Sessions nutzen für den File-Upload

Multiupload per JavaScript unterstützen

Es gibt die Möglichkeit, mit JavaScript Dateien in Teilen hochzuladen, die dann von einem serverseitigen Script wieder zusammengefügt werden müssen. Diese Vorgehensweise ist besonders dann sinnvoll, wenn man auf der einen Seite eine Größenbeschränkung für Uploads hat, und wenn man beim Hochladen mehrerer Dateien einen gewissen Komfort haben möchte.

Die schwedische Firma Moxiecode hat ein JavaScript-Werkzeug erstellt, mit dem sich das Hochladen von Dateien sehr komfortabel auch in kleinen Datenhäppchen umsetzen lässt: Plupload. Im Zusammenspiel mit einem PHP-Script soll hier gezeigt werden, wie man mit Plupload einen Uploader bauen kann:

Uploader mit Plupload und PHP
<?php
/**
 * function to handle a file upload via Plupload UI widget
 * (http://plupload.com)
 *
 * This function also checks the $_POST array for two more strings:
 * 'chunk' (current number, 0-based) and
 * 'chunks' (total number of chunks).
 *
 * @param string key for the $_FILES array
 * @param string relative file path
 * @param string allowed file type ('image', 'html' or default '*' for any type)
 */
function handle_file_upload ($key, $file, $do_backup = true, $allowed_type = '*') {

    $backup = $file;

    // new filename for already existing file (and older copies)?
    if ($do_backup && is_file($file)) {

        while (is_file($backup)) {

            // add '_old' before file type extension
            $backup = preg_replace(
                '~(\.[^.]+)$~',
                '_old$1',
                $backup
            );
        }
    }

    if (array_key_exists($key, $_FILES)
        && $_FILES[$key]['error'] === 0
    ) {

        /* let's presume we either got the last chunk or
         * a one-chunk upload */
        $ready = true;

        if (array_key_exists('chunk', $_POST)
            && array_key_exists('chunks', $_POST)
        ) {

            // first chunk?
            if ($_POST['chunk'] < 1) {

                // avoid overwriting of existing files
                if ($backup !== $file) {
                    rename($file, $backup);
                }

                // start a new file
                $fh = fopen($file, 'wb+');

            } else {

                $backup = $file;

                // continue partial file
                $fh = fopen($file, 'ab+');
            }

            // not last chunk?
            if ($_POST['chunk'] + 1 < $_POST['chunks']) {
                $ready = false;
            }

        } else {

            // avoid overwriting of existing files
            if ($backup !== $file) {
                rename($file, $backup);
            }

            // start a new file
            $fh = fopen($file, 'wb+');
        }

        // copy file data from temp file
        $tmp = fopen($_FILES[$key]['tmp_name'], 'rb');

        while ($chunk = fread($tmp, 1024*1024)) {
            fwrite($fh, $chunk);
        }

        fclose($tmp);
        fclose($fh);

        // test for allowed file type
        if ($ready) {

            $ok = ($allowed_type == '*');

            switch ($allowed_type) {

                case 'image':
                    $tmp = getimagesize($file);

                    $ok = (
                        // width > 0px?
                        $tmp[0] > 0
                        // height > 0px?
                        && $tmp[1] > 0
                        // extension OK?
                        && preg_match(
                            '~(?i)\.(gif|jpeg|jpg|png)$~',
                            $file
                        )
                    );
                break;

                case 'html':
                    $ok = (
                        // is data HTML code?
                        preg_match(
                            '~(?is)<html[^>]*>.*</html>~',
                            file_get_contents($file)
                        )
                        // filename extension OK?
                        && preg_match('~(?i)\.html?$~', $file)
                    );
                break;
            }

            if (!$ok) {

                // delete uploaded file
                unlink($file);

                // restore old file name on backup?
                if ($backup !== $file) {
                    rename($backup, $file);
                }
            }
        }
    }
}

/**
 * we define 'upload-file' as the name for the POST parameter
 */
$key = 'upload-file';

/**
 * deal with upload?
 */
if (array_key_exists($key, $_FILES)) {

    handle_file_upload(
        // 'upload-file' for $_POST['upload-file'] and $_FILES['upload-file']
        $key,
        // filename and path for storage
        sprintf(
            './uploaded/%s',
            // remove any unwanted path info from file name
            preg_replace('~^.*(/|\\\\)~', '', $_FILES[$key]['name'])
        ),
        // yes, please rename existing file first
        true,
        // accept both HTML and image files
        '*'
    );
}

/**
 * Now the HTML document
 */
?>
<!DOCTYPE html>
<html>
    <head>
        <title>File Uploader</title>
        <script src="./plupload/plupload.full.min.js" type="text/javascript"></script>
    </head>
    <body>
        <h1>File Uploader</h1>
        <p>This uploads both HTML and image files.</p>
        <div id="filelist">Your browser doesn't have Flash, Silverlight or HTML5 support.</div>
        <p id="container">
            <a id="pickfiles" href="javascript:;">[Select files]</a>
            <a id="uploadfiles" href="javascript:;">[Upload files]</a>
        </p>
        <pre id="console"></pre>
        <script type="text/javascript">
//<![CDATA[
new function () {
    // see http://plupload.com/examples/core for this code
    var uploader = new plupload.Uploader({
        runtimes : 'html5,flash,silverlight,html4',

        browse_button : "pickfiles", // you can pass in id...
        container: document.getElementById("container"), // ... or DOM Element itself

        file_data_name : "<?php echo $key; ?>",
        url : "<?php echo $_SERVER['SCRIPT_NAME']; ?>",

        filters : {
            max_file_size : "2mb",
            mime_types: [
                {title : "Image files", extensions : "jpeg,jpg,gif,png"},
                {title : "HTML files", extensions : "htm,html"}
            ]
        },

        // Flash settings
        flash_swf_url : "./plupload/js/Moxie.swf",

        // Silverlight settings
        silverlight_xap_url : "./plupload/js/Moxie.xap",


        init: {
            PostInit: function() {
                document.getElementById("filelist").innerHTML = '';

                document.getElementById("uploadfiles").onclick = function() {
                    uploader.start();
                    return false;
                };
            },

            FilesAdded: function(up, files) {
                plupload.each(files, function(file) {
                    document.getElementById("filelist").innerHTML += '<div id="'
                        + file.id
                        + '">'
                        + file.name
                        + " ("
                        + plupload.formatSize(file.size)
                        + ") <b></b></div>";
                });
            },

            UploadProgress: function(up, file) {
                document.getElementById(file.id).getElementsByTagName("b")[0].innerHTML = "<span>"
                    + file.percent
                    + "%</span>";
            },

            Error: function(up, err) {
                document.getElementById("console").innerHTML += "\nError #" + err.code + ": " + err.message;
            }
        }
    });

    uploader.init();
}
        //]]></script>
    </body>
</html>

Damit das obige PHP-Script auf einem Testsystem funktioniert, benötigt man folgende Dinge:

  • Plupload-Dateien entpackt in einem Unterverzeichnis "plupload" (Bezugsquelle Plupload.com)
  • ein Unterverzeichnis "uploaded" für die hochgeladenen Dateien
  • obiges Script als PHP-Datei im selben Verzeichnis wie die beiden Unterverzeichnisse

die PHP-Logik

Das PHP-Script definiert einen Schlüssel mit dem Wert "upload-file", mit dem die Downloads an den Server geposted werden müssen, da sie sonst nicht angenommen werden. Dieser Schlüssel findet sich auch im JavaScript-Code wieder, wo er von PHP bei der Auslieferung des HTML-Dokuments hineingeschrieben wird.

Die PHP-Funktion handle_file_upload regelt das Entgegennehmen der jeweils hochgeladenen Datei bzw. des aktuellen Fragments. Dabei achtet sie darauf, ob eine bereits vorhandene Datei gleichen Namens vorher umbenannt werden soll und setzt gegebenenfalls ein Suffix an den Dateinamen. Außerdem führt sie eine Prüfung durch, ob die hochgeladene Datei eine gültige Bilddatei oder ein gültiges HTML-Dokument ist, und ob eine Datei dieses Typs entgegengenommen werden darf. Im Zweifelsfalle löscht sie die Datei wieder. Prüfungen auf andere Dateitypen sind zwar denkbar, hier jedoch nicht umgesetzt. Sie lassen sich an entsprechender Stelle aber nachrüsten.

Durch das Senden von Fragmenten lässt sich die Prüfung auf einen gültigen Dateityp nur nach dem vollständigen Hochladen sinnvoll durchführen. Daher müssen die Fragmente aus dem temporären Verzeichnis in den eigentlichen Zielordner gespeichert und dort zusammengesetzt werden. Ein anderes Verzeichnis des Webprojektes, welches durch eine Zugriffsbeschränkung von außen nicht erreichbar ist, wäre als Zwischenspeicher vor der endgültigen Prüfung denkbar, ist im obigen Beispiel aber nicht umgesetzt, da solche Dinge je nach Projekt sehr unterschiedlich zu lösen sind.

die JavaScript-Logik mit Plupload

Das Plupload-Objekt kennt eine Menge an Einstellungsmöglichkeiten, unter anderem den Schlüssel, mit dem die Daten binär versendet werden sollen (in unserem Beispiel "upload-file"). Dieser Schlüssel ist über die Eigenschaft file_data_name definierbar und wird von PHP dort auch eingetragen. Auch die Zieladresse trägt PHP in der Eigenschaft URL ein - nämlich die Adresse des aktuell laufenden PHP-Scripts.

Plupload zerlegt nun die aktuell hochzuladende Datei in Häppchen zu 2MB (siehe Eigenschaft filters.max_file_size). Je nach Beschränkung in den PHP-Einstellungen des eigenen Webprojekts muss dieser Wert angepasst werden, da das hochgeladene Fragment von PHP ansonsten verworfen und der Upload insgesamt scheitern würde, wenn zu viele Daten auf einmal geposted würden.

Für die Auswahl der hochzuladenden Dateien und für den Upload-Vorgang an sich kann über die Konfiguration filters.mime_types eine Liste an zulässigen Dateitypen definiert werden. Diese Beschränkung macht aber das Überprüfen auf gültige Dateitypen im PHP-Code keinesfalls überflüssig!

Die restlichen Konfigurationsmöglichkeiten regeln das Handling innerhalb des HTML-Dokuments, wie z.B. die Liste der Dateien, die auf das Hochladen warten, und wieviel Prozent davon schon hochgeladen wurden. Auch was die Darstellung angeht, bietet Plupload eine ganze Reihe von weiteren Möglichkeiten.

Anders als bei "herkömmlichen" Upload-Formularen wird hier das HTML-Dokument nicht mehr neu geladen, nachdem eine Datei hochgeladen wurde. Stattdessen erhält man die Rückmeldung über JavaScript ins aktuelle Dokument eingeblendet. Aus diesem Grund braucht es im HTML-Dokument überhaupt kein Formular mehr. Plupload bedient sich einfacher HTML-Elemente wie <div>, <a> und <pre>, um sie mit Funktionalität zu erweitern. Andere Block- und Inline-Elemente sind ebenso geeignet, Plupload benötigt in seiner Konfiguration lediglich die ID des jeweiligen Elements.


Mögliche Erweiterungen

Um diesen Artikel nicht zu komplex und zu lang werden zu lassen, wird hier nur ein einfacher Dateiupload ohne Extras gezeigt. Es gibt jedoch dutzende Erweiterungsmöglichkeiten:

  • Speichern des ursprünglichen Dateinamens in einer Datenbank und sein anschließendes Mitteilen per Content-Disposition-Header an den Client. Anmerkungen:
    • Zeilenumbrüche sollten aus dem Dateinamen entfernt werden, da diese einen anderen Header einleiten; die header()-Funktion von PHP erlaubt seit der bereits als historisch zu betrachtenden Version 5.1.2 allerdings nur das Senden eines einzelnen HTTP-Headers pro Aufruf. Schadhafte Auswirkungen einer Manipulation des Dateinamens wären also unwahrscheinlich.
    • Problematisch sind bei der Angabe des ursprünglichen Dateinamens im HTTP-Header über den Zeichensatz von ASCII hinausgehende Zeichen, beispielsweise Umlaute. Um diese zu kodieren, kommt der in RFC 8187 spezifizierte Mechanismus zum Einsatz.
  • Fortschrittsbalken während des Uploads anzeigen:
    PHP stellt den Fortschritt beim Hochladen in einer Session-Variable zur Verfügung. Diese kann in einem PHP-Skript ausgelesen, ausgegeben und von der hochladenden Webseite per AjaX abgefragt werden. Für die Darstellung des Fortschritts eignet sich das progress-Element.
  • Hochladen mehrerer Dateien gleichzeitig
    • Änderungen am HTML-Formular:
      • Mehrere Eingabeelemente, die jeweils eine Datei akzeptieren:
        <input type="file" name="datei[]"> <input type="file" name="datei[]">
        
      • Ein Eingabeelement, das mehrere Dateien annimmt:
        <input type="file" name="datei[]" multiple>
        
    • nötige Änderungen am PHP-Code: Das $_FILES-Array enthält für jede hochgeladene Datei ein Array. In diesem Fall muss die Verarbeitung im Skript für jede Datei erfolgen. Dies lässt sich elegant mit einer foreach-Schleife lösen.
  • übergroße Dateien client- oder serverseitig verkleinern, um Speicherplatz und (im Falle des clientseitigen Verkleinerns) Bandbreite zu sparen, dies aber nicht den Nutzer von Hand erledigen zu lassen. Hierfür kann auf dem Server die GD-Library zum Einsatz kommen, die mit PHP ausgeliefert wird.
  • Vorschau und Dateigröße nach dem Auswählen einer Datei anzeigen: Zugriff auf Dateien von Webapplikationen (MDN)

Weblinks

Quellen

  1. Wikipedia: Dateiname
  2. OWASP Cheat Sheet Series: XML
  3. OWASP Cheat Sheet Series: XML External Entity Prevention
  4. Golem.de: Cross-Site-Scripting: Javascript-Code in Bilder einbetten
  5. SELFHTML-Forum: Wiki: Dateiupload mit PHP
  6. golem.de: Cross-Site-Scripting: Javascript-Code in Bilder einbetten