147 lines
12 KiB
PHP
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";
|