PHP-Uploads nach Größe und MIME-Typ mit Open Source filtern
Ein Upload namens photo.png kann einfachen Text enthalten. Auch der vom Browser
übermittelte Inhaltstyp kann beliebig sein. Erstellen Sie einen PHP-Endpunkt, der die empfangenen
Bytes prüft, nur die gewählten Typen und den festgelegten Größenbereich zulässt und zugelassene
Dateien außerhalb des Webroots speichert. Wir verwenden Respect\Validation für die Zulassungsregeln
und anschließend HTML Purifier für die separate Aufgabe, ein HTML-Feld zu bereinigen.
Upload-Regeln festlegen
Dieses lokale Beispiel akzeptiert nicht leere JPEG-, PNG- und GIF-Uploads bis 5 MiB, also
5 * 1024 * 1024 = 5242880 Bytes. Die
Fileinfo-Erweiterung von PHP erkennt anhand der temporären Datei
einen MIME-Typ. Der Handler misst die Bytegröße dieser Datei, ignoriert den ursprünglichen Dateinamen
und den Multipart-Inhaltstyp, wählt eine eigene Dateiendung und gibt erst nach dem Speichern der
Datei HTTP 201 zurück.
Diese Prüfungen entscheiden über die Zulassung. Sie decodieren kein Bild, entfernen keine eingebetteten Inhalte und erkennen keine Malware. Ein beschädigtes Bild oder eine Datei mit zusätzlichen Daten kann dennoch einer MIME-Regel entsprechen. Halten Sie die zugelassenen Bytes privat, bis die von Ihrer Anwendung erforderliche Decodierung oder Sicherheitsprüfung erfolgreich abgeschlossen ist.
Die Anleitung zu Upload-Größen in PHP erklärt, wie Sie die aktive
php.ini finden und Anfragegrenzen diagnostizieren. Hier liegen die Grenzen
von PHP bewusst über dem Anwendungslimit, damit Sie die Zulassungsentscheidung beobachten können.
Lokales Projekt einrichten
Verwenden Sie Bash unter Linux, PHP 8.5.10 mit aktiviertem Fileinfo, DOM und mbstring, Composer 2.10.3
und cURL. Die folgenden Beispiele verwenden Respect\Validation 3.1.2 und HTML Purifier 4.19.1.
Die Migrationshinweise für Version 3 von Respect erklären, warum
ältere Beispiele mit Validator, max() oder booleschen
Ergebnissen von validate() aktualisiert werden müssen.
Führen Sie diesen Block in einem Verzeichnis aus, in dem Sie ein neues Projekt erstellen können.
Er bricht ab, wenn das Verzeichnis php-filter-demo bereits existiert. Durch die
Subshell bleibt Ihr Terminal im ursprünglichen Verzeichnis, auch wenn die Installation fehlschlägt.
Der Befehl wählt das neue Manifest und die lokalen Abhängigkeitspfade explizit aus, sodass die
Installation weder in einem übergeordneten Composer-Projekt noch an geerbten
abweichenden Pfaden landet.
(
set -eu
mkdir php-filter-demo
cd php-filter-demo
printf '%s\n' '{"require": {}}' > composer.json
COMPOSER=./composer.json COMPOSER_VENDOR_DIR=vendor COMPOSER_BIN_DIR=vendor/bin \
composer require --no-interaction --no-plugins --no-scripts \
respect/validation:^3.1 ezyang/htmlpurifier:^4.19
mkdir public private cache
chmod 700 private cache
)
Fahren Sie erst fort, wenn die Einrichtung erfolgreich abgeschlossen ist. Führen Sie auch die
folgenden Befehle in demselben übergeordneten Verzeichnis aus. Speichern Sie die PHP-Dateien unter
den unten angegebenen Pfaden. Das ausgelieferte Verzeichnis ist php-filter-demo/public;
Abhängigkeiten, zwischengespeicherte HTML-Definitionen und Uploads bleiben außerhalb davon.
Upload-Handler speichern
Speichern Sie dieses vollständige Programm als php-filter-demo/public/upload.php. PHP befüllt
$_FILES, wenn es die Multipart-Anfrage empfängt. Ein künstlich erstelltes
Array in einem Kommandozeilenskript würde die Prüfung durch
is_uploaded_file() nicht bestehen.
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use Respect\Validation\ValidatorBuilder as v;
const MAX_BYTES = 5 * 1024 * 1024;
header('Content-Type: application/json');
function rejectUpload(int $status, string $code, string $message): never {
http_response_code($status);
echo json_encode(['accepted' => false, 'code' => $code, 'message' => $message], JSON_THROW_ON_ERROR);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
rejectUpload(405, 'method', 'Send one file in a multipart POST request.');
}
$postLimit = ini_parse_quantity(ini_get('post_max_size'));
$contentLength = $_SERVER['CONTENT_LENGTH'] ?? null;
if ($postLimit > 0 && $contentLength !== null && (int) $contentLength > $postLimit) {
rejectUpload(413, 'request_size', 'The complete request exceeds PHP post_max_size.');
}
$file = $_FILES['upload'] ?? null;
if (array_keys($_FILES) !== ['upload'] || !is_array($file)
|| !is_int($file['error'] ?? null) || !is_string($file['tmp_name'] ?? null)) {
rejectUpload(400, 'missing_upload', 'Send one file in the upload field, without array brackets.');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
[$status, $code, $message] = match ($file['error']) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => [413, 'upload_size', 'PHP rejected the file size.'],
UPLOAD_ERR_NO_FILE => [400, 'missing_upload', 'Choose a file to upload.'],
UPLOAD_ERR_PARTIAL => [400, 'partial_upload', 'The file arrived incomplete. Retry the upload.'],
default => [500, 'upload_unavailable', 'The upload service is unavailable.'],
};
rejectUpload($status, $code, $message);
}
if (!is_uploaded_file($file['tmp_name'])) {
rejectUpload(400, 'invalid_upload', 'The file is not a valid HTTP upload.');
}
$directory = null;
$destination = null;
try {
$size = filesize($file['tmp_name']);
if ($size === false) {
throw new RuntimeException('Cannot measure upload');
}
if ($size === 0) {
rejectUpload(400, 'empty_upload', 'The file is empty.');
}
if (!v::intType()->between(1, MAX_BYTES)->isValid($size)) {
rejectUpload(413, 'file_size', 'The file exceeds the 5 MiB application limit.');
}
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!v::in(array_keys($extensions))->isValid($mime)) {
rejectUpload(415, 'file_type', 'The detected type must be JPEG, PNG, or GIF.');
}
$hash = hash_file('sha256', $file['tmp_name']);
if ($hash === false) {
throw new RuntimeException('Cannot hash upload');
}
$id = bin2hex(random_bytes(16));
$candidate = dirname(__DIR__) . '/private/' . $id;
// Reserve a fresh directory so an existing upload cannot be replaced.
if (!@mkdir($candidate, 0700)) {
throw new RuntimeException('Cannot reserve storage');
}
$directory = $candidate;
$destination = $directory . '/file.' . $extensions[$mime];
if (!@move_uploaded_file($file['tmp_name'], $destination) || !@chmod($destination, 0600)) {
throw new RuntimeException('Cannot store upload');
}
http_response_code(201);
echo json_encode([
'accepted' => true,
'id' => $id,
'mime' => $mime,
'bytes' => $size,
'sha256' => $hash,
], JSON_THROW_ON_ERROR);
} catch (Throwable $error) {
if ($destination !== null) {
@unlink($destination);
}
if ($directory !== null) {
@rmdir($directory);
}
error_log('Upload storage failed: ' . get_class($error));
rejectUpload(500, 'upload_unavailable', 'The upload service is unavailable.');
}
Die Regel between() schließt den exakten Grenzwert von 5 MiB ein. Die
MIME-Zulassungsliste verwendet das Ergebnis von Fileinfo, nicht den Multipart-Header. PHP
dokumentiert die Upload-Fehlercodes getrennt von der Validierung
durch die Anwendung.
Jede akzeptierte Anfrage erhält eine neue ID, selbst wenn die Bytes identisch sind. Schlägt die
Reservierung dieser ID fehl, gibt der Handler 500 zurück und lässt das bestehende Verzeichnis
unverändert. Das ist wichtig, denn
move_uploaded_file() überschreibt ein vorhandenes Ziel.
Erfolgreich gespeicherte Dateien bleiben unter private/<id>/file.<extension> mit dem Modus
0600 in einem Verzeichnis mit dem Modus 0700.
Der ursprüngliche Dateiname wird nie zum Speicherpfad.
Server starten und eine echte Datei senden
Wählen Sie einen freien lokalen Port. Wenn 8787 belegt ist, ändern Sie ihn im Serverbefehl und in beiden Anfragen. Starten Sie den Entwicklungsserver von PHP:
php -d file_uploads=1 -d upload_max_filesize=6M -d post_max_size=7M \
-d display_errors=0 -d log_errors=1 \
-S 127.0.0.1:8787 -t php-filter-demo/public
Lassen Sie dieses Terminal weiterlaufen. Öffnen Sie in einem zweiten Terminal dasselbe
übergeordnete Verzeichnis. Speichern Sie Folgendes als php-filter-demo/make-sample.php.
Das Programm erstellt eine winzige PNG-Datei und ersetzt keine bereits vorhandene Datei
sample.png.
<?php
declare(strict_types=1);
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg==', true);
$output = @fopen(__DIR__ . '/sample.png', 'xb');
if ($output === false) {
fwrite(STDERR, "Cannot create sample.png; use the existing sample or choose a fresh project.\n");
exit(1);
}
if (fwrite($output, $png) !== strlen($png) || !fclose($output)) {
fwrite(STDERR, "Cannot finish sample.png.\n");
exit(1);
}
Erstellen Sie die Datei und laden Sie sie hoch:
php php-filter-demo/make-sample.php &&
curl -sS -i -F 'upload=@php-filter-demo/sample.png' http://127.0.0.1:8787/upload.php
Erwarten Sie HTTP 201 und JSON mit accepted: true, mime: "image/png",
bytes: 67, einer zufälligen id und einem Wert für
sha256. Kopieren Sie die zurückgegebene ID in diesen Befehl und ersetzen
Sie damit ID:
cmp php-filter-demo/sample.png php-filter-demo/private/ID/file.png
Keine Ausgabe und der Status null bedeuten, dass die gespeicherten Bytes übereinstimmen. Der Server
liefert nur public/ aus, daher hat die private Datei keine Download-URL.
Um den Upload zu wiederholen, führen Sie nur den cURL-Befehl aus. Jede Zulassung erstellt eine
weitere private Datei.
Senden Sie nun Text, der als Bild getarnt ist. Bei cURL liefert ;type=image/png den
gefälschten MIME-Header; ;filename=photo.png liefert den gefälschten Dateinamen.
Keiner der beiden Werte bestimmt den erkannten Typ:
printf '%s\n' 'This is text, not an image.' > php-filter-demo/disguised.png &&
curl -sS -i -F 'upload=@php-filter-demo/disguised.png;type=image/png;filename=photo.png' \
http://127.0.0.1:8787/upload.php
Erwarten Sie HTTP 415 mit accepted: false und code: "file_type", ohne
einen neu gespeicherten Upload. Bei erneuter Ausführung ersetzt dieser Befehl die Beispieldatei
disguised.png. Die Diagnoseanfragen lassen die cURL-Option
--fail weg, damit Sie die Antwortkörper bei Ablehnungen lesen können.
Eine erfolgreiche cURL-Übertragung bedeutet nicht, dass der Server die Datei akzeptiert hat.
| Antwort | Bedeutung |
|---|---|
| 201 | Die Datei hat die Zulassungsregeln erfüllt und wurde gespeichert |
| 400 | Fehlender, fehlerhafter, unvollständiger oder leerer Upload |
| 413 | Das Datei-/Anfragelimit von PHP oder das Anwendungslimit von 5 MiB hat zur Ablehnung geführt; prüfen Sie code |
| 415 | Der erkannte MIME-Typ steht nicht auf der Zulassungsliste |
| 500 | Upload oder privater Speicher ist nicht verfügbar; es wurde keine Zulassung gemeldet |
Die Diagnose der Anfragegröße verwendet Content-Length, das diese cURL-Anfragen
mitliefern. PHP kann $_FILES leeren, wenn post_max_size
überschritten wird. Ohne einen Längen-Header kann dieser Handler die Situation nicht von einer
fehlenden Datei unterscheiden. Erzwingen Sie auf Ihrem produktiv eingesetzten Webserver Grenzen
für die gesamte Anfrage. Beenden Sie den Entwicklungsserver nach Abschluss mit Strg+C. Zugelassene
Dateien bleiben im Beispielverzeichnis private/, bis Sie sie entfernen.
HTML Purifier für sichere HTML-Inhalte
Die HTML-Bereinigung wandelt eine HTML-Zeichenfolge um. Sie spielt keine Rolle bei der Entscheidung, ob eine hochgeladene PNG-Datei einer MIME-Regel entspricht. HTML Purifier analysiert übermitteltes Markup und wendet eine Zulassungsliste für Elemente und Attribute an.
HTML Purifier verwenden
Speichern Sie dieses separate Beispiel als php-filter-demo/sanitize.php. Die Zulassungsliste
erhält Absätze, fette und kursive Hervorhebungen sowie Zeilenumbrüche. Sie lässt keine Attribute,
Links oder Bilder zu. Cache.SerializerPath hält die von der Bibliothek erzeugten
Definitionen im privaten Cache des Projekts.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,strong,em,br');
$config->set('Cache.SerializerPath', __DIR__ . '/cache');
$purifier = new HTMLPurifier($config);
$dirtyHtml = '<script>alert("bad")</script><p onclick="bad()" style="color:red">Hello <strong>PHP</strong><img src="x" onerror="bad()"></p>';
echo $purifier->purify($dirtyHtml), PHP_EOL;
Führen Sie es im übergeordneten Verzeichnis aus:
php php-filter-demo/sanitize.php
Erwartete Ausgabe:
<p>Hello <strong>PHP</strong></p>
Fügen Sie Elemente oder Attribute nur hinzu, wenn Ihr Rich-Text-Feld sie benötigt. Die
Direktive HTML.Allowed steuert diese Richtlinie.
Verwenden Sie das Ergebnis als Fragment für den HTML-Body. Es ist kein Escaping für eine
JavaScript-Zeichenfolge, einen CSS-Wert oder ein HTML-Attribut. Verwenden Sie für ein Klartextfeld
bei der Ausgabe kontextbezogenes Escaping, etwa htmlspecialchars(). Verwenden Sie beim
Speichern beider Feldarten vorbereitete SQL-Anweisungen. Die Bereinigung macht SQL-Interpolation
nicht sicher.
Wann welche Bibliothek sinnvoll ist
Verwenden Sie Respect\Validation, wenn Sie wie im Handler Regeln für die Zulassung oder für Formulardaten kombinieren möchten. Verwenden Sie HTML Purifier, wenn ein Feld bewusst HTML akzeptiert. Falls Ihre Anwendung bereits Symfony Validator nutzt, bietet dessen File-Constraint Prüfungen für Dateigröße und Dateiendung/MIME-Typ. Belassen Sie die Constraints von Symfony in Ihrem bestehenden Validierungsablauf, statt für dieses Beispiel ein zweites Validierungsframework zu installieren.
Der lokale Endpunkt hat keine Authentifizierung, Benutzerkontingente oder Malware-Scanner. Sein Limit von 5 MiB begrenzt jede zugelassene Datei, nicht den gesamten Speicher oder den Speicherbedarf decodierter Bilder. Legen Sie vor der öffentlichen Bereitstellung fest, wer hochladen darf und welcher Decoder oder Scanner die privaten Bytes freigeben muss, bevor sie verfügbar werden.
Dateifilterung mit Transloadit
Für eine Verarbeitungspipeline wählt der Robot 🤖 /file/filter Dateien anhand von Metadaten für nachfolgende Steps in einer Assembly aus. Das ist unabhängig von der Speicherentscheidung des lokalen PHP-Handlers. Zum Beispiel:
{
"steps": {
":original": {
"robot": "/upload/handle"
},
"filter_images": {
"use": ":original",
"robot": "/file/filter",
"condition_type": "and",
"accepts": [
["${file.mime}", "regex", "image"],
["${file.meta.width}", ">=", 100]
],
"declines": [["${file.size}", ">", 10485760]]
}
}
}
Beide Zulassungsbedingungen müssen erfüllt sein, und Dateien über 10 MiB werden abgelehnt.
Diese Metadatenregel belegt nicht, dass ein Bild harmlos ist. Der Robot akzeptiert auch
JavaScript-Bedingungen, etwa diesen alternativen Wert für accepts
für Dateien im Querformat unter 500.000 Bytes:
{
"accepts": "${file.meta.width > file.meta.height && file.size < 500000}"
}
Die Robot-Dokumentation erklärt den Vorrang von Bedingungen und die zusätzlichen Gebühren für die JavaScript-Auswertung. Verwenden Sie das PHP SDK, wenn Sie eine PHP-Anwendung an diesen Verarbeitungsablauf anbinden müssen.
