Files
deskhub/DeskHub-Webseite/tests/mail_attachments.php
T
2026-11-04 07:55:36 +00:00

147 lines
12 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft die Anhänge abgerufener E-Mails: Anhänge werden erkannt (auch mit kodierten Dateinamen), eingebettete Signaturbilder nicht,
* die Dateien landen als Anhang der Nachricht im Ticket, und was nicht übernommen wird (Typ nicht erlaubt, zu groß), steht im Verlauf.
*/
$uploads = sys_get_temp_dir() . '/deskhub-mailatt-' . bin2hex(random_bytes(4));
mkdir($uploads, 0755, true);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('DESKHUB_CONFIG_JSON=' . json_encode(['uploads' => ['path' => $uploads, 'max_size_bytes' => 5000, 'allowed_extensions' => ['png', 'jpg', 'pdf', 'txt', 'docx']]]));
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once dirname(__DIR__) . '/includes/mail_text.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
$check(realpath((string) config()['uploads']['path']) === realpath($uploads), 'Test nutzt einen eigenen Upload-Ordner: ' . (string) config()['uploads']['path']);
/** Baut einen Teil wie imap_fetchstructure ihn liefert. */
$param = fn(string $a, string $v) => (object) ['attribute' => $a, 'value' => $v];
$leaf = function (int $type, string $subtype, int $enc, int $bytes, array $dparams = [], string $disp = '', bool $ifid = false) use ($param): object {
$o = (object) ['type' => $type, 'subtype' => $subtype, 'encoding' => $enc, 'bytes' => $bytes, 'ifid' => $ifid];
if ($ifid) $o->id = '<logo1@example.de>';
if ($disp !== '') { $o->ifdisposition = 1; $o->disposition = $disp; }
if ($dparams) { $o->ifdparameters = 1; $o->dparameters = array_map(fn($p) => $param($p[0], $p[1]), $dparams); }
return $o;
};
$docx = random_bytes(1200); // Word-Datei (Inhalt zufällig, nur die Endung zählt)
$parts = [ // Rohinhalt je Teilnummer (Teil 1 = Text, 2 = Word, 3 = Signaturbild, 4 = zu große PDF, 5 = exe)
'2' => chunk_split(base64_encode($docx)),
'4' => base64_encode(str_repeat('x', 8000)),
'5' => base64_encode('MZ-programm'),
];
$structure = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [
(object) ['type' => 1, 'subtype' => 'ALTERNATIVE', 'parts' => [$leaf(0, 'PLAIN', 3, 40)]],
$leaf(3, 'VND.OPENXMLFORMATS-OFFICEDOCUMENT.WORDPROCESSINGML.DOCUMENT', 3, 1600, [['filename', 'Haus Ledwin .docx']], 'attachment'),
$leaf(5, 'GIF', 3, 100, [], 'inline', true), // Signaturbild mit Content-ID
$leaf(3, 'PDF', 3, 11000, [['filename', 'gross.pdf']], 'attachment'),
$leaf(3, 'OCTET-STREAM', 3, 80, [['filename', 'programm.exe']], 'attachment'),
]];
$found = mail_collect_attachment_parts($structure);
$check(count($found) === 4, 'vier Teile erkannt (Word, Signaturbild, PDF, exe): ' . json_encode(array_column($found, 'name')));
$inl = array_values(array_filter($found, fn($f) => $f['inline']));
$check(count($inl) === 1 && $inl[0]['cid'] === 'logo1@example.de', 'Signaturbild mit Content-ID ist als eingebettet gekennzeichnet');
$check(($found[0]['name'] ?? '') === 'Haus Ledwin .docx' && ($found[0]['no'] ?? '') === '2', 'Word-Datei mit Teilnummer 2 erkannt');
// Dateinamen: MIME-kodiert und RFC 2231
$check(mail_decode_filename('=?UTF-8?B?' . base64_encode('Übersicht März.pdf') . '?=') === 'Übersicht März.pdf', 'MIME-Dateiname (Base64) wird dekodiert');
$check(mail_decode_filename('=?ISO-8859-1?Q?Gr=FC=DFe.txt?=') === 'Grüße.txt', 'MIME-Dateiname (Quoted-Printable) wird dekodiert');
$check(mail_decode_filename("UTF-8''Bericht%20f%C3%BCr%20Mai.docx") === 'Bericht für Mai.docx', 'RFC-2231-Dateiname wird dekodiert');
$check(mail_decode_filename('einfach.txt') === 'einfach.txt', 'einfacher Dateiname bleibt');
// Teil ohne Dateinamen, aber als Anhang gekennzeichnet; reiner Text ohne Namen ist kein Anhang
$check(count(mail_collect_attachment_parts($leaf(3, 'PDF', 3, 500, [], 'attachment'))) === 1, 'Anhang ohne Dateinamen bekommt einen Namen');
$check(mail_collect_attachment_parts($leaf(0, 'PLAIN', 0, 500)) === [], 'Textteil ohne Namen ist kein Anhang');
// Verschachtelt: Anhang in multipart/related innerhalb multipart/mixed (Teilnummer 1.2)
$nested = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [(object) ['type' => 1, 'subtype' => 'RELATED', 'parts' => [$leaf(0, 'HTML', 0, 10), $leaf(3, 'PDF', 3, 100, [['name', 'a.pdf']], 'attachment')]]]];
$n = mail_collect_attachment_parts($nested);
$check(count($n) === 1 && $n[0]['no'] === '1.2', 'verschachtelter Anhang hat die Teilnummer 1.2: ' . json_encode($n));
// Speichern
$nowStr = date('Y-m-d H:i:s');
$pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES ('260101-0001','Test','Olaf','olaf@example.de',1,2,'open','email','{$nowStr}','{$nowStr}')");
$ticketId = (int) $pdo->lastInsertId();
$pdo->exec("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES ({$ticketId},'customer','x',0,'{$nowStr}')");
$threadId = (int) $pdo->lastInsertId();
$log = [];
mail_store_attachments($pdo, $structure, fn(string $no) => $parts[$no] ?? '', $threadId, $ticketId, 'Test', $log, false, true);
$rows = $pdo->query("SELECT * FROM ticket_attachments WHERE thread_id = {$threadId}")->fetchAll(PDO::FETCH_ASSOC);
$check(count($rows) === 1 && $rows[0]['original_name'] === 'Haus Ledwin .docx', 'nur die Word-Datei wird gespeichert: ' . json_encode(array_column($rows, 'original_name')));
$stored = $rows ? $uploads . '/' . $rows[0]['stored_name'] : '';
$check($stored !== '' && is_file($stored) && file_get_contents($stored) === $docx, 'Dateiinhalt stimmt mit dem Anhang überein');
$check($rows && (int) $rows[0]['filesize'] === strlen($docx), 'Dateigröße stimmt');
$act = $pdo->query("SELECT action FROM activity_log WHERE ticket_id = {$ticketId}")->fetchAll(PDO::FETCH_COLUMN);
$joined = implode("\n", $act);
$check(str_contains($joined, 'gross.pdf') && str_contains($joined, 'zu groß'), 'zu großer Anhang steht im Verlauf: ' . $joined);
$check(str_contains($joined, 'programm.exe'), 'gesperrter Typ steht im Verlauf');
$check(count(array_filter($log, fn($l) => str_contains($l, 'übernommen: Haus Ledwin'))) === 1, 'Protokollzeile für übernommenen Anhang');
// Eingebettetes Bild (echtes PNG) mit Content-ID: wird als eingebettet gespeichert und nicht als Anhang gezählt
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==');
$inStruct = (object) ['type' => 1, 'subtype' => 'RELATED', 'parts' => [$leaf(0, 'HTML', 3, 40), $leaf(5, 'PNG', 3, 200, [], 'inline', true)]];
$log3 = [];
$c3 = mail_store_attachments($pdo, $inStruct, fn(string $no) => $no === '2' ? chunk_split(base64_encode($png)) : '', $threadId, $ticketId, 'Test', $log3, false, true);
$row = $pdo->query("SELECT * FROM ticket_attachments WHERE thread_id = {$threadId} AND is_inline = 1")->fetch(PDO::FETCH_ASSOC);
$check($c3['inline'] === 1 && $row && $row['content_id'] === 'logo1@example.de' && str_ends_with($row['original_name'], '.png'), 'eingebettetes Bild gespeichert (Content-ID, .png): ' . json_encode($row));
$c3b = mail_store_attachments($pdo, $inStruct, fn(string $no) => $no === '2' ? chunk_split(base64_encode($png)) : '', $threadId, $ticketId, 'Test', $log3, true, true);
$check($c3b['existing'] === 1 && $c3b['inline'] === 0, 'eingebettetes Bild wird beim zweiten Mal nicht doppelt gespeichert');
// Ohne HTML-Fassung ist dasselbe Bild ein normaler Anhang (in der Liste sichtbar)
$noHtml = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [$leaf(0, 'PLAIN', 3, 40), $leaf(5, 'PNG', 3, 200, [['name', 'foto.png']], 'inline', true)]];
$c4 = mail_store_attachments($pdo, $noHtml, fn(string $no) => $no === '2' ? chunk_split(base64_encode($png)) : '', $threadId, $ticketId, 'Test', $log3, false, false);
$check($c4['added'] === 1 && (int) $pdo->query("SELECT is_inline FROM ticket_attachments WHERE thread_id = {$threadId} AND original_name = 'foto.png'")->fetchColumn() === 0, 'ohne HTML-Fassung zählt das Bild als Anhang');
// Ein "Bild", das keins ist, wird nicht als eingebettetes Bild gespeichert
$fake = (object) ['type' => 1, 'subtype' => 'RELATED', 'parts' => [$leaf(0, 'HTML', 3, 40), $leaf(5, 'PNG', 3, 100, [['name', 'x.png']], 'inline', true)]];
$before2 = (int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn();
mail_store_attachments($pdo, $fake, fn(string $no) => $no === '2' ? base64_encode('<html><script>alert(1)</script></html>') : '', $threadId, $ticketId, 'Test', $log3, false, true);
$check((int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn() === $before2, 'HTML im Bild-Gewand wird nicht gespeichert');
// Weitergeleitete Mail (.eml): Name aus dem Betreff
$rfc = "From: a@example.de
Subject: =?UTF-8?B?" . base64_encode('Rechnung März') . "?=
Inhalt";
$emlStruct = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [$leaf(0, 'PLAIN', 3, 40), $leaf(2, 'RFC822', 0, strlen($rfc), [], '')]];
$c5 = mail_store_attachments($pdo, $emlStruct, fn(string $no) => $no === '2' ? $rfc : '', $threadId, $ticketId, 'Test', $log3, false, true);
$check($c5['added'] === 1 && (int) $pdo->query("SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = {$threadId} AND original_name = 'Rechnung März.eml'")->fetchColumn() === 1, 'weitergeleitete Mail wird als .eml gespeichert, Name aus dem Betreff');
// Zusätzliche Endungen nur für Mails: "doc" ist im Formular nicht erlaubt (Liste im Test: png, jpg, pdf, txt, docx), per Mail schon
$docStruct = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [$leaf(0, 'PLAIN', 3, 40), $leaf(3, 'MSWORD', 3, 300, [['filename', 'alt.doc']], 'attachment'), $leaf(3, 'OCTET-STREAM', 3, 300, [['filename', 'schaedling.php']], 'attachment')]];
$c6 = mail_store_attachments($pdo, $docStruct, fn(string $no) => base64_encode('Dokument ' . $no), $threadId, $ticketId, 'Test', $log3, false, false);
$check($c6['added'] === 1 && $c6['skipped'] === 1 && (int) $pdo->query("SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = {$threadId} AND original_name = 'alt.doc'")->fetchColumn() === 1, 'doc ist per Mail erlaubt, .php bleibt gesperrt: ' . json_encode($c6));
$check(isset(handle_ticket_upload(['name' => 'alt.doc', 'type' => '', 'tmp_name' => __FILE__, 'error' => UPLOAD_ERR_OK, 'size' => 10], false)['error']), 'im Formular bleibt doc gesperrt (die Zusatzliste gilt nur für Mails)');
set_setting('mail_attachment_extra_extensions', 'xyz');
$c7 = mail_store_attachments($pdo, (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [$leaf(0, 'PLAIN', 3, 40), $leaf(3, 'MSWORD', 3, 300, [['filename', 'neu.doc']], 'attachment')]], fn(string $no) => base64_encode('x'), $threadId, $ticketId, 'Test', $log3, false, false);
$check($c7['added'] === 0 && $c7['skipped'] === 1, 'Zusatzliste ist einstellbar (mit "xyz" ist doc wieder gesperrt)');
set_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT);
// Mail ohne Anhänge: nichts passiert
$before = (int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn();
$log2 = [];
mail_store_attachments($pdo, (object) ['type' => 0, 'subtype' => 'PLAIN', 'encoding' => 0, 'bytes' => 10], fn($n) => 'x', $threadId, $ticketId, 'Test', $log2);
$check((int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn() === $before && $log2 === [], 'Mail ohne Anhang: keine Einträge');
// Gefährliche Namen
$res = store_ticket_attachment_data($pdo, $threadId, '../../evil.php', 'x');
$check(!$res['ok'], 'Pfad und .php werden abgelehnt');
$res = store_ticket_attachment_data($pdo, $threadId, "a\x00b.txt", 'inhalt');
$check($res['ok'] && $res['name'] === 'ab.txt', 'Steuerzeichen im Namen werden entfernt: ' . json_encode($res));
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
$rm($uploads);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Mail attachment checks passed.\n";