Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\Database;
use OpenNIT\Tickets\TicketManager;
/**
* Deckt die Aufnahme von Anhängen aus eingehenden Nachrichten ab.
*
* Der Medientyp eines Anhangs bestimmt die Software des Absenders. Ohne
* Angleichung entschiede damit das Mailprogramm des Absenders darüber, ob ein
* Anhang im Ticket ankommt – und ein abgewiesener Anhang verschwände, ohne dass
* jemand davon erfährt.
*/
return static function (): void {
/** Wird der Typ nach der Angleichung übernommen? */
$uebernommen = static function (string $mime, string $name): bool {
return in_array(
TicketManager::normalizeAttachmentMime($mime, $name),
TicketManager::getAllowedAttachmentMimeTypes(),
true
);
};
// ----------------------------------------------------------------
// Schreibweisen einzelner Mailprogramme
// ----------------------------------------------------------------
test('Die Schreibweise von Outlook für ZIP wird angeglichen', function () {
// Outlook meldet ZIP-Dateien als application/x-zip-compressed.
assertSame(
'application/zip',
TicketManager::normalizeAttachmentMime('application/x-zip-compressed', 'bericht.zip')
);
});
test('Verbreitete abweichende Schreibweisen werden zusammengeführt', function () {
foreach ([
['image/jpg', 'image/jpeg'],
['image/pjpeg', 'image/jpeg'],
['image/x-png', 'image/png'],
['application/x-pdf', 'application/pdf'],
['application/x-gzip', 'application/gzip'],
['application/x-rar', 'application/vnd.rar'],
] as [$gemeldet, $erwartet]) {
assertSame($erwartet, TicketManager::normalizeAttachmentMime($gemeldet, 'datei'), $gemeldet);
}
});
test('Parameter und Großschreibung fallen weg', function () {
// Der Kopfzeilen-Wert trägt oft den Dateinamen als Parameter mit.
assertSame(
'application/pdf',
TicketManager::normalizeAttachmentMime('APPLICATION/PDF; name="Angebot.pdf"', 'Angebot.pdf')
);
});
// ----------------------------------------------------------------
// Unbestimmter Typ
// ----------------------------------------------------------------
test('Bei unbestimmtem Typ entscheidet die Dateiendung', function () use ($uebernommen) {
// Viele Mailprogramme setzen für jeden Anhang application/octet-stream.
foreach ([
['Angebot.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
['foto.JPG', 'image/jpeg'],
['tabelle.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
['notiz.txt', 'text/plain'],
] as [$name, $erwartet]) {
assertSame($erwartet, TicketManager::normalizeAttachmentMime('application/octet-stream', $name), $name);
assertTrue($uebernommen('application/octet-stream', $name), 'Verworfen: ' . $name);
}
});
test('Auch ein leerer Typ wird über die Endung bestimmt', function () use ($uebernommen) {
assertTrue($uebernommen('', 'tabelle.xlsx'));
});
test('Ohne verwertbare Endung bleibt der Typ unbestimmt', function () use ($uebernommen) {
// Die Angleichung soll die Prüfung nicht aushebeln: Was sich weder aus
// Typ noch Endung bestimmen lässt, wird weiterhin abgewiesen.
assertSame(
'application/octet-stream',
TicketManager::normalizeAttachmentMime('application/octet-stream', 'unbekannt.xyz')
);
assertFalse($uebernommen('application/octet-stream', 'unbekannt.xyz'));
});
test('Ausführbare Inhalte werden nicht über die Endung hereingeholt', function () use ($uebernommen) {
foreach (['schaden.exe', 'skript.php', 'start.bat', 'lauf.sh'] as $name) {
assertFalse($uebernommen('application/octet-stream', $name), 'Angenommen: ' . $name);
}
});
test('Wo der Typ aus dem Inhalt stammt, überstimmt die Endung ihn nicht', function () {
// Bei Uploads über die Oberfläche ermittelt die Anwendung den Typ aus
// dem Dateiinhalt. Würde die Endung ihn ersetzen, genügte ein passend
// benannter Dateiname, um an der Prüfung vorbeizukommen.
assertSame(
'application/octet-stream',
TicketManager::normalizeAttachmentMime('application/octet-stream', 'harmlos.docx', false)
);
// Die reine Angleichung der Schreibweise bleibt auch dort erhalten.
assertSame(
'application/zip',
TicketManager::normalizeAttachmentMime('application/x-zip-compressed', 'x.zip', false)
);
});
test('Weitergeleitete Nachrichten sind zugelassen', function () use ($uebernommen) {
assertTrue($uebernommen('message/rfc822', 'weitergeleitet.eml'));
assertTrue($uebernommen('application/octet-stream', 'weitergeleitet.eml'));
});
// ----------------------------------------------------------------
// Vermerk über nicht übernommene Anhänge
// ----------------------------------------------------------------
/** Datenbank-Attrappe, die Einfügungen mitschreibt. */
$db = static function (): Database {
return new class extends Database {
/** @var array<int,array{table:string,data:array}> */
public array $inserts = [];
public function __construct()
{
parent::__construct([]);
}
public function insert(string $table, array $data): int
{
$this->inserts[] = ['table' => $table, 'data' => $data];
return 1;
}
};
};
/** Erzeugt den Manager ohne Konstruktor. */
$manager = static function (Database $datenbank): TicketManager {
$klasse = new ReflectionClass(TicketManager::class);
$mgr = $klasse->newInstanceWithoutConstructor();
$feld = $klasse->getProperty('db');
$feld->setAccessible(true);
$feld->setValue($mgr, $datenbank);
return $mgr;
};
test('Ein nicht übernommener Anhang wird im Ticket vermerkt', function () use ($db, $manager) {
$datenbank = $db();
$manager($datenbank)->addDroppedAttachmentNote(7, [
'Angebot.exe' => 'Dateityp nicht erlaubt (application/octet-stream)',
]);
assertSame(1, count($datenbank->inserts), 'Kein Vermerk angelegt');
$eintrag = $datenbank->inserts[0];
assertSame('ticket_replies', $eintrag['table']);
assertSame(7, $eintrag['data']['ticket_id']);
// Intern, damit der Vermerk nicht an den Melder hinausgeht.
assertSame(1, $eintrag['data']['is_internal']);
// Dateiname und Grund müssen darin stehen – sonst hilft der Vermerk nicht.
assertTrue(str_contains($eintrag['data']['body'], 'Angebot.exe'), 'Dateiname fehlt');
assertTrue(str_contains($eintrag['data']['body'], 'Dateityp nicht erlaubt'), 'Grund fehlt');
});
test('Der Dateiname im Vermerk wird escaped', function () use ($db, $manager) {
// Der Name stammt aus der eingegangenen Nachricht.
$datenbank = $db();
$manager($datenbank)->addDroppedAttachmentNote(7, [
'<img src=x onerror=alert(1)>.exe' => 'Dateityp nicht erlaubt',
]);
$body = $datenbank->inserts[0]['data']['body'];
assertFalse(str_contains($body, '<img src=x'), 'Der Dateiname steht ungeschützt im Vermerk');
assertTrue(str_contains($body, '<img'), 'Der Dateiname fehlt ganz');
});
test('Ohne verworfene Anhänge entsteht kein Vermerk', function () use ($db, $manager) {
$datenbank = $db();
$manager($datenbank)->addDroppedAttachmentNote(7, []);
assertSame(0, count($datenbank->inserts));
});
test('Mehrere verworfene Anhänge stehen in einem Vermerk', function () use ($db, $manager) {
$datenbank = $db();
$manager($datenbank)->addDroppedAttachmentNote(7, [
'a.exe' => 'Dateityp nicht erlaubt',
'b.iso' => 'Größe überschritten (300 MB, erlaubt sind 25 MB)',
]);
assertSame(1, count($datenbank->inserts), 'Je Anhang ein eigener Vermerk');
$body = $datenbank->inserts[0]['data']['body'];
assertTrue(str_contains($body, 'a.exe') && str_contains($body, 'b.iso'));
assertTrue(str_contains($body, '2 Anhänge'), 'Die Anzahl wird nicht genannt');
});
};