Tutorial: Einen sicheren Plugin-Uploader für Bludit bauen
WordPress-ähnlicher ZIP-Upload für bl-plugins – mit Adminroute, CSRF, ZIP-Slip-Schutz, Limits und Strukturprüfung.
Die offizielle Standardinstallation eines Bludit-Plugins läuft über Entpacken und Kopieren nach bl-plugins/. Für einen WordPress-ähnlichen Workflow kann man einen ZIP-Uploader als Plugin bauen. Der kritische Teil ist nicht der Datei-Input, sondern die sichere Behandlung des Archivs.
Der in diesem Projekt enthaltene Uploader kann als md-plugin-uploader-1.0.0.zip geladen werden.
Bedrohungsmodell
- Ein manipuliertes ZIP versucht mit
../außerhalb vonbl-pluginszu schreiben. - Ein ZIP-Bomb-Archiv expandiert auf sehr große Datenmengen.
- Ein Paket überschreibt ein bereits installiertes Plugin.
- Ein Nicht-Admin versucht die Uploadroute aufzurufen.
- Ein fremdes Plugin enthält absichtlich schädlichen PHP-Code – das kann kein Uploader zuverlässig „wegscannen“. Deshalb bleibt die Vertrauensfrage bestehen.
1. Admin-only und CSRF
Der Tutorial-Uploader ruft checkRole(array('admin')) auf und verwendet den Bludit-CSRF-Token im Formular. Damit reicht ein eingeloggter Nicht-Admin nicht aus, und fremde Webseiten können nicht einfach einen Upload-POST im Namen des Administrators auslösen.
2. Uploadgrenzen
private $maxZipBytes = 10485760; // 10 MiB
private $maxFiles = 500;
private $maxExpandedBytes = 52428800; // 50 MiB
3. Nicht extractTo() auf fremde Archive
Der Uploader liest jeden ZIP-Eintrag einzeln. Absolute Pfade, Windows-Laufwerkspräfixe und jedes ..-Segment werden verworfen. Erst dann wird ein Zielpfad innerhalb des temporären Workspaces erzeugt.
4. Plugin-Struktur prüfen
Nach dem Entpacken wird genau ein Plugin-Paket gesucht. Es muss plugin.php, metadata.json und einen languages/-Ordner mit mindestens einer JSON-Datei besitzen. metadata.json wird außerdem wirklich geparst.
5. Kein stilles Überschreiben
Existiert das Zielverzeichnis schon, bricht der Beispiel-Uploader ab. Ein Update-Mechanismus ist ein eigenes Problem: Er braucht Backup, Versionsvergleich, Konfliktbehandlung und idealerweise atomaren Austausch. Für ein Installations-Tutorial ist „existiert bereits → Stop“ die sichere Grundregel.
Vollständige plugin.php des Uploaders
<?php defined('BLUDIT') or die('Bludit CMS.');
class pluginMDPluginUploader extends Plugin
{
private $maxZipBytes = 10485760; // 10 MiB compressed upload
private $maxFiles = 500;
private $maxExpandedBytes = 52428800; // 50 MiB uncompressed
public function init()
{
$this->dbFields = array();
}
public function adminController()
{
checkRole(array('admin'));
global $layout;
$layout['title'] = 'Plugin Uploader | Bludit';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
}
$workspace = $this->workspace().'upload-'.bin2hex(random_bytes(6)).DS;
try {
if (!extension_loaded('zip')) {
throw new Exception('Die PHP-Erweiterung ZIP ist nicht installiert.');
}
if (!isset($_FILES['packageZip'])) {
throw new Exception('Es wurde keine ZIP-Datei übertragen.');
}
$file = $_FILES['packageZip'];
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new Exception('Der Upload ist mit Fehlercode '.(int)$file['error'].' fehlgeschlagen.');
}
if ((int)$file['size'] <= 0 || (int)$file['size'] > $this->maxZipBytes) {
throw new Exception('Das ZIP muss zwischen 1 Byte und 10 MiB groß sein.');
}
if (Text::lowercase(pathinfo($file['name'], PATHINFO_EXTENSION)) !== 'zip') {
throw new Exception('Erlaubt sind ausschließlich ZIP-Dateien.');
}
mkdir($workspace, DIR_PERMISSIONS, true);
$this->safeExtract($file['tmp_name'], $workspace);
$packageRoot = $this->findPackageRoot($workspace, 'plugin.php');
$this->validatePackage($packageRoot, 'plugin.php', 'Plugin');
$slug = basename(rtrim($packageRoot, DS));
if ($packageRoot === rtrim($workspace, DS)) {
$slug = pathinfo($file['name'], PATHINFO_FILENAME);
}
$slug = Text::lowercase($slug);
$slug = preg_replace('/[^a-z0-9_-]+/', '-', $slug);
$slug = trim($slug, '-_');
if ($slug === '' || !preg_match('/^[a-z0-9][a-z0-9_-]*$/', $slug)) {
throw new Exception('Aus dem Paket konnte kein sicherer Verzeichnisname ermittelt werden.');
}
$destination = PATH_PLUGINS.$slug;
if (Filesystem::directoryExists($destination)) {
throw new Exception('Der Ordner '.$slug.' existiert bereits. Aus Sicherheitsgründen wird nichts überschrieben.');
}
if (!Filesystem::copyRecursive($packageRoot, $destination)) {
throw new Exception('Das Paket konnte nicht in das Zielverzeichnis kopiert werden.');
}
Filesystem::deleteRecursive(rtrim($workspace, DS));
Alert::set('Plugin „'.htmlspecialchars($slug, ENT_QUOTES, 'UTF-8').'“ wurde hochgeladen. Jetzt in der '.('Plugin'==='Plugin'?'Plugin-':'Theme-').'Verwaltung prüfen und aktivieren.');
Redirect::page('plugin/'.Text::lowercase(__CLASS__));
} catch (Exception $e) {
if (Filesystem::directoryExists(rtrim($workspace, DS))) {
Filesystem::deleteRecursive(rtrim($workspace, DS));
}
Alert::set($e->getMessage(), ALERT_STATUS_FAIL);
}
}
public function adminView()
{
checkRole(array('admin'));
global $security;
$token = htmlspecialchars($security->getTokenCSRF(), ENT_QUOTES, 'UTF-8');
$target = 'PATH_PLUGINS' === 'PATH_PLUGINS' ? 'bl-plugins/' : 'bl-themes/';
$kind = 'Plugin';
$html = '<div class="container-fluid px-0">';
$html .= '<h2>'.$this->name().'</h2>';
$html .= '<p class="text-muted">ZIP hochladen → Struktur prüfen → sicher entpacken → nach <code>'.$target.'</code> kopieren. Vorhandene Ordner werden nie überschrieben.</p>';
if (!extension_loaded('zip')) {
$html .= '<div class="alert alert-danger">PHP-ZIP ist nicht verfügbar. Der Uploader kann auf diesem Server keine Archive lesen.</div></div>';
return $html;
}
$html .= '<div class="alert alert-warning"><strong>Sicherheitsregel:</strong> Installiere nur Pakete aus Quellen, denen du vertraust. Ein '.$kind.' besteht aus PHP/JS/CSS und kann nach der Aktivierung Code auf deiner Website ausführen.</div>';
$html .= '<form method="post" enctype="multipart/form-data" class="card"><div class="card-body">';
$html .= '<input type="hidden" name="tokenCSRF" value="'.$token.'">';
$html .= '<div class="form-group"><label for="packageZip">'.$kind.'-ZIP</label><input class="form-control-file" type="file" id="packageZip" name="packageZip" accept=".zip,application/zip" required><small class="form-text text-muted">Maximal 10 MiB; maximal 500 Dateien und 50 MiB entpackt.</small></div>';
$html .= '<button type="submit" class="btn btn-primary">ZIP prüfen und hochladen</button>';
$html .= '</div></form>';
$html .= '<hr><h3>Was geprüft wird</h3><ul><li>ZIP-Dateiendung und Uploadfehler</li><li>kein absoluter Pfad, kein <code>..</code> und damit Schutz vor ZIP-Slip</li><li>Dateianzahl und entpackte Gesamtgröße</li><li><code>plugin.php</code>, <code>metadata.json</code> und ein <code>languages</code>-Ordner</li><li>gültige JSON-Metadaten</li><li>kein Überschreiben bestehender Pakete</li></ul>';
$html .= '</div>';
return $html;
}
public function adminSidebar()
{
$url = HTML_PATH_ADMIN_ROOT.'plugin/'.Text::lowercase(__CLASS__);
return '<a class="nav-link" href="'.$url.'">Plugin Uploader</a>';
}
private function safeExtract($zipFile, $destination)
{
$zip = new ZipArchive();
if ($zip->open($zipFile) !== true) {
throw new Exception('Das ZIP-Archiv konnte nicht geöffnet werden.');
}
if ($zip->numFiles < 1 || $zip->numFiles > $this->maxFiles) {
$zip->close();
throw new Exception('Das ZIP enthält zu viele oder keine Dateien.');
}
$expanded = 0;
for ($i=0; $i<$zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = isset($stat['name']) ? str_replace('\\', '/', $stat['name']) : '';
if ($name === '' || strpos($name, "\0") !== false || preg_match('#^(?:/|[A-Za-z]:/)#', $name)) {
$zip->close();
throw new Exception('Unsicherer Pfad im ZIP gefunden.');
}
$parts = array();
foreach (explode('/', $name) as $part) {
if ($part === '' || $part === '.') continue;
if ($part === '..') {
$zip->close();
throw new Exception('ZIP-Slip-Versuch mit .. im Dateipfad erkannt.');
}
$parts[] = $part;
}
if (empty($parts)) continue;
$target = rtrim($destination, DS).DS.implode(DS, $parts);
$isDirectory = substr($name, -1) === '/';
if ($isDirectory) {
if (!Filesystem::directoryExists($target) && !mkdir($target, DIR_PERMISSIONS, true)) {
$zip->close();
throw new Exception('Ein Verzeichnis konnte nicht angelegt werden.');
}
continue;
}
$expanded += isset($stat['size']) ? (int)$stat['size'] : 0;
if ($expanded > $this->maxExpandedBytes) {
$zip->close();
throw new Exception('Das Archiv überschreitet 50 MiB entpackte Gesamtgröße.');
}
$dir = dirname($target);
if (!Filesystem::directoryExists($dir) && !mkdir($dir, DIR_PERMISSIONS, true)) {
$zip->close();
throw new Exception('Ein Zielverzeichnis konnte nicht angelegt werden.');
}
$input = $zip->getStream($stat['name']);
if (!$input) {
$zip->close();
throw new Exception('Eine ZIP-Datei konnte nicht gelesen werden.');
}
$output = fopen($target, 'wb');
if (!$output) {
fclose($input); $zip->close();
throw new Exception('Eine entpackte Datei konnte nicht geschrieben werden.');
}
stream_copy_to_stream($input, $output);
fclose($input); fclose($output);
}
$zip->close();
}
private function findPackageRoot($workspace, $requiredFile)
{
$root = rtrim($workspace, DS);
if (is_file($root.DS.$requiredFile) && is_file($root.DS.'metadata.json')) {
return $root;
}
$candidates = array();
foreach (Filesystem::listDirectories($root) as $directory) {
if (is_file($directory.DS.$requiredFile) && is_file($directory.DS.'metadata.json')) {
$candidates[] = $directory;
}
}
if (count($candidates) !== 1) {
throw new Exception('Das ZIP muss genau ein '.$requiredFile.'-Paket im Archivwurzelverzeichnis oder in genau einem direkten Unterordner enthalten.');
}
return $candidates[0];
}
private function validatePackage($root, $requiredFile, $kind)
{
if (!is_file($root.DS.$requiredFile) || !is_file($root.DS.'metadata.json') || !is_dir($root.DS.'languages')) {
throw new Exception($kind.'-Struktur unvollständig: '.$requiredFile.', metadata.json und languages/ werden benötigt.');
}
$metadata = json_decode(file_get_contents($root.DS.'metadata.json'), true);
if (!is_array($metadata) || json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('metadata.json ist kein gültiges JSON.');
}
$languageFiles = glob($root.DS.'languages'.DS.'*.json');
if (!$languageFiles) {
throw new Exception('Im languages-Ordner wurde keine JSON-Sprachdatei gefunden.');
}
}
}
Was dieser Uploader bewusst nicht verspricht
Er prüft Archivstruktur und Pfadsicherheit. Er kann nicht beweisen, dass der PHP-Code eines fremden Plugins vertrauenswürdig ist. Installiere und aktiviere deshalb nur Pakete aus nachvollziehbaren Quellen.