Sichere Datei-Uploads für kleine PHP-Anwendungen — Namen, Typen, Speicherung und Auslieferung kontrollieren
Ein Formular zum Hochladen von Dateien wirkt wie eine kleine Funktion: eine Datei annehmen, in ein Verzeichnis verschieben und ihren Namen speichern. Die schwierige Frage beginnt einen Schritt später. Welche Teile dieser Datei kennt die Anwendung tatsächlich, und welche sind lediglich Behauptungen eines nicht vertrauenswürdigen Clients?
Die sicherste Antwort besteht nicht darin, einen einzigen perfekten Test zu finden. Stattdessen braucht es einen engen Pfad, auf dem jede Entscheidung unter Kontrolle der Anwendung bleibt: Wer darf hochladen, wie groß darf die Anfrage sein, welche Formate erfüllen einen echten Zweck, wie wird ein gespeichertes Objekt benannt, wo liegt es und wie kann es abgerufen werden? Dieser Artikel untersucht diesen Pfad für eine kleine PHP-Anwendung. Er behauptet nicht, dass ein kurzes Codebeispiel beliebige Dateien sicher machen kann.
Ein Upload überschreitet mehrere Vertrauensgrenzen
Ein hochgeladenes Objekt kommt mit Bytes und Metadaten an. In PHP kann $_FILES den ursprünglichen Namen, einen temporären Pfad, die Größe, einen Fehlercode und einen Medientyp enthalten. Die PHP-Dokumentation zu Uploads erklärt jedoch ausdrücklich, dass der vom Browser gemeldete MIME-Typ nicht von PHP geprüft wird und nicht als gegeben betrachtet werden darf. Auch der übermittelte Name und der vollständige Pfad sind Eingaben des Clients.
Diese Unterscheidung ist wichtig, weil eine Datei auf unterschiedliche Weise Schaden verursachen kann. Ein Server könnte sie ausführen, ein Bild- oder Dokumentparser könnte eine Schwachstelle enthalten, aktive Inhalte könnten Leser angreifen, ein sehr großes Objekt könnte den Speicher erschöpfen oder ein vertraut wirkender Dateiname könnte ein vorhandenes Objekt überschreiben. CWE-434 beschreibt die zentrale Schwachstelle als Zulassen eines gefährlichen Dateityps, den die empfangende Umgebung anschließend verarbeitet.
Die Datei selbst ist daher nur ein Teil des Problems. Gefährlich ist die Kombination aus Inhalt, Interpretation und Berechtigung. Eine PHP-Datei außerhalb eines öffentlichen Verzeichnisbaums, die nie an einen Interpreter übergeben wird, birgt ein anderes Risiko als dieselben Bytes unter einer URL, an der der Webserver sie ausführt. Ein gutes Upload-Design kontrolliert diesen Kontext, anstatt jede denkbare schädliche Bytefolge erkennen zu wollen.
Mit dem kleinsten sinnvollen Vertrag beginnen
Bevor Validierungscode entsteht, sollte feststehen, was die Funktion tatsächlich benötigt. „Eine Datei hochladen“ ist zu weit gefasst. „Ein authentifizierter Redakteur darf ein JPEG- oder PNG-Bild bis 5 MiB als Artikel-Thumbnail hochladen“ ist ein Vertrag, der sich durchsetzen und prüfen lässt.
Ein sinnvoller Vertrag beantwortet mindestens diese Fragen:
- Wer darf ein Objekt erstellen, ersetzen, lesen und löschen?
- Welche genauen Formate benötigt die Funktion?
- Wie groß dürfen Anfrage, Datei, Pixelabmessungen und Speicherverbrauch sein?
- Wird der Server das Objekt parsen, skalieren, extrahieren oder anderweitig umwandeln?
- Muss das Objekt öffentlich, privat, temporär oder dauerhaft aufbewahrt sein?
- Was soll geschehen, wenn Validierung, Speicherung oder spätere Verarbeitung fehlschlagen?
Aus diesem Vertrag folgt eine Allowlist. Benötigt eine Funktion nur JPEG und PNG, vergrößert die Annahme von SVG, HTML, PDF, ZIP und jedem unbekannten Binärformat die Angriffsfläche, ohne einen Nutzen zu schaffen. Das OWASP File Upload Cheat Sheet empfiehlt, nur Erweiterungen zuzulassen, die für die Geschäftsfunktion erforderlich sind. Eine Denylist wie „.php ablehnen“ müsste jeden gefährlichen Typ und jede mögliche Interpretation eines Namens durch die ausliefernde Umgebung vorhersehen.
Übertragungsfehler vor der Inhaltsprüfung ablehnen
Die Upload-Verarbeitung muss zuerst feststellen, ob PHP einen vollständigen Upload empfangen hat. PHP stellt den Status über das Feld error und die UPLOAD_ERR_*-Konstanten bereit. Nur UPLOAD_ERR_OK steht für eine erfolgreiche Übertragung. Ein unvollständiger Upload, ein fehlendes temporäres Verzeichnis oder ein Schreibfehler ist keine Datei, die man „trotzdem versuchen“ sollte, sondern eine fehlgeschlagene Anfrage.
Größenbeschränkungen sollten auf mehreren Ebenen bestehen. Ein verborgenes Formularfeld MAX_FILE_SIZE kann die Benutzerfreundlichkeit verbessern, doch die PHP-Dokumentation warnt davor, dass ein Client es verändern kann. Servereinstellungen wie post_max_size und upload_max_filesize bilden eine frühere Grenze, während der Anwendungscode weiterhin eine funktionsspezifische Grenze benötigt. Auch der Webserver oder Reverse Proxy kann eine eigene Grenze für den Request-Body haben.
Diese Grenzen ersetzen einander nicht. Eine Infrastrukturgrenze weist offensichtlich übergroßen Datenverkehr früh ab; eine Anwendungsgrenze beschreibt, was diese konkrete Funktion akzeptiert. Wenn die Anwendung Inhalte später dekomprimiert oder decodiert, ist die komprimierte Byteanzahl nicht zwangsläufig die relevante Ressourcengrenze. Pixelabmessungen, entpackte Archivgröße, Verarbeitungszeit und das gesamte Speicherkontingent können ebenfalls wichtig sein.
Namen und Typen als Hinweise behandeln, nicht als Wahrheit
Der ursprüngliche Dateiname kann für die Anzeige oder für Audit-Datensätze nützlich sein, sollte aber nicht den Speicherpfad bestimmen. Das Entfernen von Verzeichniskomponenten mit basename() löst nur einen Teil des Problems. Namen können kollidieren, problematische Zeichen enthalten, mehrere Erweiterungen verbergen oder in einem anderen Werkzeug eine besondere Bedeutung erhalten. Eine von der Anwendung erzeugte Kennung hält die Dateisystementscheidung von Benutzereingaben getrennt.
Auch die Typprüfung benötigt mehr als ein Signal. Der vom Browser angegebene Content-Type lässt sich leicht fälschen. Die Fileinfo-Erweiterung von PHP kann eine temporäre Datei untersuchen und über finfo_file() eine MIME-Klassifikation zurückgeben. Das ist nützlicher als das Vertrauen in den Request-Header, bleibt aber eine Klassifikation und ist kein Zertifikat für ungefährliche Inhalte.
Eine praktische Richtlinie kann eine kleine Allowlist für Erweiterungen mit einer kleinen Allowlist für aus dem Inhalt abgeleitete MIME-Typen vergleichen und Abweichungen ablehnen. In einem Bild-Workflow kann das Decodieren und erneute Schreiben mit einer gepflegten Bibliothek eine weitere Kontrolle ergänzen. Auch das beweist keine allgemeine Sicherheit: Der Decoder ist Software, Metadaten können Auswirkungen auf die Privatsphäre haben und Ressourcenlimits gelten weiterhin. Mehrschichtige Prüfungen verringern Unsicherheit; sie verwandeln feindliche Eingaben nicht allein durch eine Deklaration in vertrauenswürdige Eingaben.
Speichernamen und Auslieferung unter Kontrolle der Anwendung halten
OWASP und CWE-434 empfehlen beide, Uploads nach Möglichkeit außerhalb des Dokumentenstamms des Webservers zu speichern. Dadurch wird der einfachste Weg von „der Server hat diese Bytes angenommen“ zu „der Webserver interpretiert diese Bytes unter einer öffentlichen URL“ unterbrochen. Ein separater Host oder Objektspeicher kann eine stärkere Grenze schaffen, doch auch eine kleine Anwendung gewinnt durch ein nicht öffentliches Verzeichnis eine sinnvolle Trennung.
Private Speicherung bedeutet, dass Downloads einen Handler benötigen. Dieser zusätzliche Schritt ist nützlich: Die Anwendung kann eine opake ID einem Speichernamen zuordnen, die Berechtigung prüfen, einen sicheren Content-Type für die Antwort wählen, Content-Disposition setzen und entscheiden, ob eine Inline-Darstellung angemessen ist. Der ursprüngliche Name kann als sorgfältig codierte Download-Metainformation geliefert werden, ohne zu einem Dateisystempfad zu werden.
Upload- und Leseberechtigung sind getrennte Entscheidungen. Ein Benutzer, der ein privates Dokument übermitteln darf, darf dadurch nicht automatisch jedes Dokument anderer Benutzer abrufen. Ebenso sollte ein Redakteur, der ein öffentliches Thumbnail hochladen darf, nicht zwangsläufig jeden beliebigen Medieneintrag ersetzen können. Authentifizierung beantwortet, wer ein Benutzer ist; Autorisierung muss weiterhin beantworten, was dieser Benutzer mit diesem konkreten Objekt tun darf. Zustandsändernde Upload-Anfragen benötigen außerdem den üblichen CSRF-Schutz der Anwendung.
Ein bewusst eng begrenztes PHP-Beispiel
Das folgende Beispiel akzeptiert ein JPEG- oder PNG-Bild, begrenzt es auf 5 MiB, leitet seinen MIME-Typ aus der temporären Datei ab, erzeugt einen zufälligen Speichernamen und verschiebt es in ein Verzeichnis außerhalb des Webroots. Das Verzeichnis muss bereits vorhanden und für den PHP-Worker beschreibbar sein. Das Beispiel lässt Authentifizierung, Autorisierung, CSRF-Prüfung, Datenbankpersistenz, Bilddecodierung, Kontingente und Bereinigung aus, weil diese Kontrollen von der umgebenden Anwendung abhängen.
<?php
declare(strict_types=1);
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const UPLOAD_DIRECTORY = '/srv/example-app/uploads';
if (!isset($_FILES['image']) || !is_array($_FILES['image'])) {
throw new RuntimeException('Missing upload.');
}
$upload = $_FILES['image'];
if (($upload['error'] ?? null) !== UPLOAD_ERR_OK) {
throw new RuntimeException('The upload did not complete successfully.');
}
$temporaryPath = $upload['tmp_name'] ?? '';
$actualSize = is_string($temporaryPath) ? filesize($temporaryPath) : false;
if ($actualSize === false || $actualSize < 1 || $actualSize > MAX_UPLOAD_BYTES) {
throw new RuntimeException('The file size is not allowed.');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($temporaryPath);
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!is_string($mimeType) || !isset($allowedTypes[$mimeType])) {
throw new RuntimeException('The file type is not allowed.');
}
$storageName = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mimeType];
$destination = UPLOAD_DIRECTORY . DIRECTORY_SEPARATOR . $storageName;
if (!move_uploaded_file($temporaryPath, $destination)) {
throw new RuntimeException('The uploaded file could not be stored.');
}
// Persist the opaque storage name and validated MIME type, not a user path.
Die offizielle Dokumentation erklärt, dass move_uploaded_file() prüft, ob die Quelle ein gültiger PHP-HTTP-POST-Upload ist. Das ist eine nützliche Herkunftsprüfung, aber keine Inhaltsvalidierung. Sie warnt außerdem, dass ein vorhandenes Ziel überschrieben wird. Ein zufälliger 128-Bit-Name macht eine unbeabsichtigte Kollision äußerst unwahrscheinlich. Systeme, die eine absolute Garantie gegen Überschreiben benötigen, brauchen jedoch Speichersemantiken, die das Ziel exklusiv anlegen, anstatt sich auf Wahrscheinlichkeit zu verlassen.
Das Beispiel vertraut auch nicht den vom Client gemeldeten Werten size oder type. Es misst die temporäre Datei und verwendet Fileinfo. Bei einer reinen Bildfunktion könnte als Nächstes das Bild decodiert, mit Dimensions- und Speichergrenzen versehen, von unnötigen Metadaten befreit und als neu codierte Ableitung geschrieben werden. Ob das angemessen ist, hängt von den erforderlichen Formaten und der gewählten Bildbibliothek ab.
Validierung ist nur der Anfang des Lebenszyklus
Eine Datei, die die Annahmeprüfungen besteht, kann später weiterhin betriebliche Probleme verursachen. Parser und Konverter benötigen Aktualisierungen, Zeitlimits, Speichergrenzen und eine ihrer Gefahr angemessene Isolation. Die Extraktion von Archiven braucht Grenzen für entpackte Inhalte und Pfade. Malware-Scanning kann nützliche Hinweise ergänzen, doch ein sauberes Ergebnis beweist nicht, dass eine Datei harmlos ist. Das Senden privater Uploads an einen externen Scan-Dienst kann selbst Daten offenlegen.
Auch der Speicher benötigt Regeln für den Lebenszyklus. Fehlgeschlagene Datenbankschreibvorgänge sollten keine dauerhaften verwaisten Dateien hinterlassen. Ersetzte Objekte sollten schließlich gelöscht werden. Protokolle sollten eine Objektkennung, den Akteur, das Ergebnis und den Ablehnungsgrund erfassen, ohne private Inhalte oder unsichere Namen in einer Oberfläche auszugeben. Backups und Replikate müssen dieselben Zugriffserwartungen wie der Primärspeicher bewahren. Öffentliche Download-Endpunkte können Ratenbegrenzungen benötigen, weil kleine Anfragen große Antworten auslösen können.
Das Prinzip der geringsten Rechte ist die letzte Grenze und kein Ersatz für Validierung. Der PHP-Worker sollte nur den tatsächlich benötigten Dateisystemzugriff besitzen. Ein Upload-Verzeichnis darf nicht zu einem Verzeichnis für ausführbare Skripte werden. Ein Verarbeitungs-Worker kann von der öffentlichen Anwendung getrennt werden, wenn Formate oder Transformationen dies rechtfertigen. Diese Kontrollen begrenzen die Auswirkungen, wenn sich eine frühere Annahme als falsch erweist.
Checkliste für die Prüfung
- Die Funktion definiert konkrete Benutzer, Formate, Grenzen, Transformationen, Sichtbarkeit und Aufbewahrung.
- Infrastruktur- und Anwendungsgrenzen lehnen übergroße oder unvollständige Anfragen ab.
- Der Servercode prüft
UPLOAD_ERR_OK, bevor er die temporäre Datei verarbeitet. - Der ursprüngliche Name und der vom Browser deklarierte MIME-Typ bestimmen niemals Vertrauen oder Speicherpfad.
- Eine kleine Allowlist für Erweiterungen und Inhaltstypen folgt den tatsächlichen Anforderungen der Funktion.
- Die Anwendung erzeugt einen opaken Speichernamen und behandelt ihre Kollisionsrichtlinie bewusst.
- Uploads liegen außerhalb des Webroots oder werden über eine Grenze ausgeliefert, die sie nicht ausführen kann.
- Upload, Ersetzen, Lesen und Löschen besitzen jeweils eigene Autorisierungsprüfungen.
- CSRF, Kontingente, Parsing-Grenzen, Bereinigung, Protokollierung, Backups und Entfernung bei Vorfällen sind abgedeckt.
- Das Team weiß, was die einzelnen Prüfungen nicht feststellen können.
Fazit
Eine sichere Upload-Pipeline ähnelt weniger der Kontrolle eines Anhangs an einer Tür als der lückenlosen Verwahrung eines unbekannten Pakets. Das Etikett ist nicht der Inhalt, das Bestehen einer Prüfung berechtigt nicht zu jeder späteren Nutzung und der Lagerraum sollte nicht zugleich Ausführungsumgebung sein.
Für eine kleine PHP-Anwendung ist es meist am wertvollsten, den Vertrag einzugrenzen: weniger Typen akzeptieren, ausdrückliche Grenzen setzen, Namen erzeugen, außerhalb des öffentlichen Verzeichnisbaums speichern und den Zugriff vermitteln. MIME-Erkennung, Neucodierung von Bildern, Scanning und Sandboxing können dort weitere Schichten bilden, wo das Bedrohungsmodell sie rechtfertigt. Keine davon verdient die Bezeichnung eines universellen Tests für eine „sichere Datei“. Das ehrliche Ziel ist eine kontrollierte Verarbeitung mit begrenzten Folgen, wenn eine Schicht versagt.
References
- PHP Documentation Group, “POST method uploads”.
- PHP Documentation Group, “Error Messages Explained”.
- PHP Documentation Group, “finfo_file”.
- PHP Documentation Group, “move_uploaded_file”.
- OWASP Cheat Sheet Series, “File Upload Cheat Sheet”.
- MITRE CWE, “CWE-434: Unrestricted Upload of File with Dangerous Type”, updated April 30, 2026.
