src/Service/MailHtmlSanitizer.php line 73

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Service;
  4. use DOMDocument;
  5. use DOMElement;
  6. use DOMNode;
  7. use DOMXPath;
  8. final class MailHtmlSanitizer
  9. {
  10. /** Tags, die bleiben dürfen */
  11. private const ALLOWED_TAGS = [
  12. 'p','div','br','span','b','strong','i','em','u','a',
  13. 'ul','ol','li',
  14. 'h1','h2','h3','h4','h5','h6',
  15. 'img',
  16. 'table','thead','tbody','tfoot','tr','td','th',
  17. 'blockquote','pre','code'
  18. ];
  19. /** Erlaubte Attribute je Tag */
  20. private const ALLOWED_ATTRS = [
  21. 'a' => ['href','title','name'],
  22. 'img' => ['src','alt','title','width','height'],
  23. 'td' => ['colspan','rowspan'],
  24. 'th' => ['colspan','rowspan'],
  25. ];
  26. /** Für <img src="data:..."> erlaubte MIME-Typen (bewusst kein SVG) */
  27. private const ALLOWED_DATA_IMAGE_MIME = [
  28. 'image/png','image/jpeg','image/gif','image/webp','image/avif',
  29. ];
  30. function normalizeHtml(string $html): string
  31. {
  32. // 1) Microsoft Word Müll entfernen
  33. $patterns = [
  34. '/class="Mso[^"]*"/i',
  35. '/style="[^"]*"/i',
  36. '/<o:p>.*?<\/o:p>/i',
  37. '/<o:p><\/o:p>/i',
  38. '/mso-[a-zA-Z0-9\-]+:[^;"]*;?/i',
  39. '/<!--.*?-->/s',
  40. ];
  41. $html = preg_replace($patterns, '', $html);
  42. // 2) Leere spans entfernen
  43. $html = preg_replace('/<span[^>]*>\s*<\/span>/i', '', $html);
  44. // 3) Word-spezifische Wrapper vereinfachen
  45. $html = preg_replace('/<div[^>]*>/i', '<div>', $html);
  46. // 4) Whitelist-Filter – jetzt MIT <img>
  47. $allowed = '<p><ul><ol><li><br><strong><em><b><i><u><a><div><img>';
  48. $html = strip_tags($html, $allowed);
  49. // 5) Mehrfache BRs aufräumen
  50. $html = preg_replace('/(<br\s*\/?>\s*){3,}/i', "<br><br>", $html);
  51. // 6) Whitespaces normalisieren
  52. $html = preg_replace('/\s+/', ' ', $html);
  53. $html = trim($html);
  54. // 7) Kleine kosmetische Bereinigung
  55. $html = str_replace(['> <', '> <'], "><", $html);
  56. return $html;
  57. }
  58. public function sanitize(string $html = null): string
  59. {
  60. if (null === $html) {
  61. return "--";
  62. }
  63. if (trim($html) === '') {
  64. return $html;
  65. }
  66. $html = html_entity_decode($html);
  67. /*
  68. // 0) Encoding normalisieren
  69. $enc = mb_detect_encoding($html, ['UTF-8','Windows-1252','ISO-8859-1','ISO-8859-15'], true) ?: 'UTF-8';
  70. $html = mb_convert_encoding($html, 'UTF-8', $enc);
  71. */
  72. // Gesamtlänge des Inhalts bestimmen
  73. if (strpos($html, '<body') !== false) {
  74. $startpos = strpos($html, '<body');
  75. $endpos = strpos($html, '</body');
  76. if (($endpos - ($startpos - 1))<10) return $html;
  77. $html = mb_substr($html, ($startpos - 1) , ($endpos - ($startpos - 1)), 'UTF-8');
  78. }
  79. $html = $this->normalizeHtml($html);
  80. $html = $this->cleanTicketHtml($html);
  81. return $html;
  82. }
  83. /**
  84. * Bereinigt Word/Outlook-HTML so, dass es das Ticketsystem nicht mehr zerschießt.
  85. * - packt das HTML in einen Wrapper <div id="wrapper">
  86. * - lässt DOMDocument die Struktur reparieren (verschachtelte p, div-Mismatch, etc.)
  87. * - entfernt leere Word-Absätze (p.MsoNormal ohne Inhalt)
  88. * - gibt nur den INNEREN Inhalt des Wrappers zurück
  89. */
  90. function cleanTicketHtml(string $html): string
  91. {
  92. // Sicherstellen, dass Encoding zu DOMDocument passt
  93. $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
  94. $dom = new DOMDocument();
  95. // Parser-Fehler unterdrücken, weil wir ja kaputtes HTML erwarten
  96. libxml_use_internal_errors(true);
  97. // Wir packen alles in einen Wrapper, damit zusätzliche </div> nicht das Layout des Systems sprengen
  98. $dom->loadHTML(
  99. '<div id="__wrapper__">'.$html.'</div>',
  100. LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
  101. );
  102. libxml_clear_errors();
  103. $xpath = new DOMXPath($dom);
  104. // 1. Leere p.MsoNormal entfernen (typischer Outlook/Word-Müll)
  105. foreach ($xpath->query('//p[@class="MsoNormal"]') as $p) {
  106. /** @var DOMElement $p */
  107. if (trim($p->textContent) === '' && !$p->hasChildNodes()) {
  108. $p->parentNode->removeChild($p);
  109. }
  110. }
  111. // 2. Wrapper-Inhalt extrahieren
  112. $wrapper = $dom->getElementById('__wrapper__');
  113. if (!$wrapper) {
  114. // Fallback: alles zurückgeben
  115. return $html;
  116. }
  117. $clean = '';
  118. foreach ($wrapper->childNodes as $child) {
  119. $clean .= $dom->saveHTML($child);
  120. }
  121. return $clean;
  122. }
  123. private function unwrap(\DOMElement $el): void
  124. {
  125. $p = $el->parentNode;
  126. if (!$p) return;
  127. while ($el->firstChild) {
  128. $p->insertBefore($el->firstChild, $el);
  129. }
  130. $p->removeChild($el);
  131. }
  132. private function cleanAttributesDeep(\DOMNode $node): void
  133. {
  134. $walker = function(\DOMNode $n) use (&$walker) {
  135. if ($n instanceof \DOMElement) {
  136. // 1) Inline-Events & CSS weg
  137. foreach (iterator_to_array($n->attributes) as $attr) {
  138. $name = strtolower($attr->name);
  139. $val = trim($attr->value);
  140. if (str_starts_with($name, 'on') || $name === 'style' || $name === 'class') {
  141. $n->removeAttributeNode($attr);
  142. continue;
  143. }
  144. // 2) Hrefs nur http/https/mailto/tel/relative
  145. if (in_array($name, ['href','xlink:href','formaction'], true)) {
  146. if (preg_match('#^\s*(javascript:|data:)#i', $val)) {
  147. $n->removeAttributeNode($attr);
  148. }
  149. }
  150. // 3) IMG-Src erlauben: http/https/cid + data:image/(keine SVG)
  151. if ($name === 'src' && $n->tagName === 'img') {
  152. $ok = preg_match('#^\s*(https?|cid):#i', $val)
  153. || preg_match('#^\s*data:(image/(png|jpeg|jpg|gif|webp|avif));base64,#i', $val);
  154. if (!$ok) {
  155. // unsicheres Bild komplett entfernen
  156. $n->parentNode?->removeChild($n);
  157. return; // Node ist weg
  158. }
  159. }
  160. // 4) Breite/Höhe/Spans: nur Zahlen
  161. if (in_array($name, ['width','height','colspan','rowspan'], true)
  162. && !preg_match('/^\d{1,5}$/', $val)) {
  163. $n->removeAttributeNode($attr);
  164. }
  165. }
  166. }
  167. // Rekursion
  168. foreach (iterator_to_array($n->childNodes) as $c) {
  169. $walker($c);
  170. }
  171. };
  172. $walker($node);
  173. }
  174. private function walk(DOMNode $node): void
  175. {
  176. // Kopie, da NodeList live ist
  177. $children = [];
  178. foreach ($node->childNodes as $c) { $children[] = $c; }
  179. foreach ($children as $child) {
  180. if ($child instanceof DOMElement) {
  181. $tag = strtolower($child->tagName);
  182. // MS-Office/sonstige Namespace-Tags (z.B. o:p, v:shape, w:...) → entpacken
  183. if (str_contains($tag, ':')) {
  184. $this->unwrap($child);
  185. continue;
  186. }
  187. // Nicht erlaubte Tags → entpacken (Kinder hochziehen)
  188. if (!in_array($tag, self::ALLOWED_TAGS, true)) {
  189. $this->unwrap($child);
  190. continue;
  191. }
  192. // Erlaubte Tags: Attribute hart bereinigen
  193. $this->cleanAttributes($child);
  194. // Tag-spezifische Checks
  195. if ($tag === 'img') {
  196. $src = $child->getAttribute('src');
  197. if (!$this->isSafeImgSrc($src)) {
  198. $child->parentNode?->removeChild($child); // unsicher → Bild weg
  199. continue;
  200. }
  201. } elseif ($tag === 'a') {
  202. $href = $child->getAttribute('href');
  203. if ($href !== '' && !$this->isSafeHref($href)) {
  204. $child->removeAttribute('href'); // Link bleibt als Text
  205. }
  206. }
  207. }
  208. // Rekursion
  209. $this->walk($child);
  210. }
  211. }
  212. private function cleanAttributes(DOMElement $el): void
  213. {
  214. $tag = strtolower($el->tagName);
  215. $allowed = self::ALLOWED_ATTRS[$tag] ?? [];
  216. // Kopie der Attribute
  217. $attrs = iterator_to_array($el->attributes);
  218. foreach ($attrs as $attr) {
  219. $name = strtolower($attr->name);
  220. $value = trim($attr->value);
  221. // Inline-Events (onclick, onerror, ...) immer verbieten
  222. if (str_starts_with($name, 'on')) {
  223. $el->removeAttributeNode($attr);
  224. continue;
  225. }
  226. // CSS/Styling konsequent entfernen
  227. if ($name === 'style' || $name === 'class') {
  228. $el->removeAttributeNode($attr);
  229. continue;
  230. }
  231. // Nur erlaubte Attributnamen behalten
  232. if (!in_array($name, $allowed, true)) {
  233. $el->removeAttributeNode($attr);
  234. continue;
  235. }
  236. // Zusätzliche Plausibilisierung
  237. if (in_array($name, ['width','height','colspan','rowspan'], true)) {
  238. if (!preg_match('/^\d{1,5}$/', $value)) {
  239. $el->removeAttributeNode($attr);
  240. }
  241. }
  242. }
  243. }
  244. private function isSafeHref(string $href): bool
  245. {
  246. // Erlaubt: http, https, mailto, tel, sowie relative Links
  247. if (preg_match('#^\s*(javascript:|data:)#i', $href)) {
  248. return false;
  249. }
  250. if (preg_match('#^\s*([a-z][a-z0-9+\-.]*):#i', $href, $m)) {
  251. $scheme = strtolower($m[1]);
  252. return in_array($scheme, ['http','https','mailto','tel'], true);
  253. }
  254. return true; // relative URL
  255. }
  256. private function isSafeImgSrc(string $src): bool
  257. {
  258. // http/https/cid erlaubt
  259. if (preg_match('#^\s*(https?|cid):#i', $src)) {
  260. return true;
  261. }
  262. // data:image/<whitelist>;base64,...
  263. if (preg_match('#^\s*data:([^;]+);base64,#i', $src, $m)) {
  264. $mime = strtolower(trim($m[1]));
  265. return in_array($mime, self::ALLOWED_DATA_IMAGE_MIME, true);
  266. }
  267. return false;
  268. }
  269. }