Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)
28.1 Restarbeit (URI-Schemata):
DANGEROUS_URI_SCHEMES jetzt vollstaendig – blob:, about:, ws:, wss:,
ldap:, dict: ergaenzt. http(s):, mailto:, tel: bewusst nicht
geblockt (legitime URLs in Notizfeldern).
29.1 Cyrillic-Homoglyph:
"jаvascript:" mit U+0430 lief durch die Regex. HOMOGLYPH_TO_ASCII-
Map (а→a, е→e, о→o, …, 13 Eintraege) wird VOR dem Scheme-Strip
angewendet.
29.2 Percent-Encoding:
"java%73cript:" und "java%2573cript:" umgingen den Filter.
percentDecode() laeuft jetzt iterativ bis zu 5 Runden.
29.3 Zero-Width-Joiner:
"javascript:" mit U+200B/200C/200D etc. zerteilte die Regex-
Matches. ZERO_WIDTH_CHARS-Regex strippt alle unsichtbaren Unicode-
Steuerzeichen, bevor irgendwas anderes laeuft.
28.3 Partial (PDF-Validierung tiefer):
Magic-Bytes allein reichten nicht – "%PDF-1.4\n#!/bin/bash" kam
durch. Jetzt zusaetzlich %%EOF-Marker in den letzten 1 KB +
Pattern-Scan der ersten 4 KB auf #!/, <script, <?php, <%, "MZ "
(PE-Header).
29.4 Email-Format-Validator:
neuer isValidEmail() lehnt Whitespace/Newlines (SMTP-Header-
Injection-Vektor) und Format-Muell ab. Verdrahtet in
create/update Customer + User + updatePortalSettings.
29.5 GET /api/providers/email 500 -> 404:
parseInt("email") = NaN, Prisma crashte. Controller validiert jetzt
Number.isFinite(id) und liefert 404.
Live-verifiziert auf dev: 13 Test-Cases (alle Schema-Varianten,
Homoglyphe, Percent, ZWJ, PDF-Validierung, Email-Format,
/providers/email) – alle erwarteten Antworten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+105
-21
@@ -237,36 +237,67 @@ const USER_CREATE_FIELDS = [
|
||||
* (z.B. PDF-Generator, E-Mail-Template oder ein dangerouslySetInnerHTML
|
||||
* im Frontend). React-Auto-Escaping fängt den normalen Fall ab, aber
|
||||
* Defense-in-Depth speichert lieber gleich nichts Bösartiges.
|
||||
* Pentest Runde 11 (2026-05-18), M2: <script>alert(1)</script> in
|
||||
* companyName landete vorher ungefiltert in der DB.
|
||||
*
|
||||
* Pentest 2026-05-20 (LOW): zusätzlich werden Skript-URI-Schemata
|
||||
* unschädlich gemacht (`javascript:`, `data:`, `vbscript:`, `file:`,
|
||||
* `ftp:`). Plain-Text-Felder enthalten legitime URLs ohnehin selten;
|
||||
* ein gespeicherter `javascript:alert(1)` würde ansonsten in einem
|
||||
* `<a href={value}>` sofort feuern. `file:` + `ftp:` ergänzt nach
|
||||
* Pentest 28.1 – kein direkter XSS, aber Data-Exfil/Lokalpfad-Vektor.
|
||||
*
|
||||
* Pentest 28.2: HTML-Entity-Decoding VOR dem Strip, sonst umgehen
|
||||
* `javascript:` und `<script>` die Regex.
|
||||
* Verlauf:
|
||||
* - Pentest Runde 11 (2026-05-18): <script>… in companyName.
|
||||
* - Pentest 2026-05-20 (28.1/28.2): URI-Schema-Liste + Entity-Decoding.
|
||||
* - Pentest 2026-05-20 (29.1/29.2/29.3): Zero-Width-Chars,
|
||||
* Percent-Encoding und Cyrillic-Homoglyph-Bypass. Reihenfolge ist
|
||||
* wichtig: zuerst Unicode-Müll raus, dann percent + entity dekodieren,
|
||||
* dann Homoglyphe normalisieren, ZULETZT Tag-/Scheme-Strip.
|
||||
* - Pentest 2026-05-20 (28.1 Restarbeit): blob:/about:/ws:/wss:/
|
||||
* ldap:/dict: ergänzt.
|
||||
*/
|
||||
const DANGEROUS_URI_SCHEMES = /(?:javascript|data|vbscript|file|ftp)\s*:/gi;
|
||||
|
||||
// Schemes die wir aktiv blocken – durch "blocked:" ersetzen statt
|
||||
// löschen, damit legitimer Text drumherum erhalten bleibt.
|
||||
// Bewusst nicht in der Liste: http(s):, mailto:, tel: (legitime URLs in
|
||||
// Notizfeldern). Alles andere geht selten in einem Plain-Text-Feld vor
|
||||
// und kann im worst case immer noch durch JS interpretiert werden.
|
||||
const DANGEROUS_URI_SCHEMES =
|
||||
/(?:javascript|data|vbscript|file|ftp|blob|about|ws|wss|ldap|dict)\s*:/gi;
|
||||
|
||||
// Unsichtbare Unicode-Steuerzeichen, die wie Whitespace fehlen, aber
|
||||
// Regex-Matches auf "javascript:" zerteilen können. Plain-Text-Felder
|
||||
// enthalten diese nie legitim. Pentest 29.3.
|
||||
// U+200B–U+200F (ZWSP, ZWNJ, ZWJ, LRM, RLM), U+202A–U+202E (Embedding/
|
||||
// Override), U+2060–U+2064 (Word-Joiner & co.), U+FEFF (BOM).
|
||||
const ZERO_WIDTH_CHARS = /[---]/g;
|
||||
|
||||
// Cyrillic/Greek-Homoglyphe, die in URL-Schemes als Spoofing taugen
|
||||
// (Pentest 29.1: "jаvascript:" mit kyrillischem а = U+0430).
|
||||
// Bewusst eng gehalten: nur die Buchstaben, die in JS-/HTML-Schlüssel-
|
||||
// wörtern vorkommen – wir wollen legitimes Russisch/Griechisch (z.B. in
|
||||
// einem Notizfeld) nicht komplett zerlegen. Das Risiko, dass ein
|
||||
// einzelnes "а" in einem ru-Wort versehentlich zu "a" wird, ist
|
||||
// akzeptabel – die häufiger genutzten cyrillischen Buchstaben (б, в,
|
||||
// г, …) sind nicht in der Map.
|
||||
const HOMOGLYPH_TO_ASCII: Record<string, string> = {
|
||||
'а': 'a', 'А': 'A', // U+0430 / U+0410
|
||||
'е': 'e', 'Е': 'E', // U+0435 / U+0415
|
||||
'о': 'o', 'О': 'O', // U+043E / U+041E
|
||||
'р': 'p', 'Р': 'P', // U+0440 / U+0420
|
||||
'с': 'c', 'С': 'C', // U+0441 / U+0421
|
||||
'у': 'y', 'У': 'Y', // U+0443 / U+0423
|
||||
'х': 'x', 'Х': 'X', // U+0445 / U+0425
|
||||
'і': 'i', 'І': 'I', // U+0456 / U+0406 (Ukr.)
|
||||
'ј': 'j', 'Ј': 'J', // U+0458 / U+0408 (Mac.)
|
||||
'ѕ': 's', 'Ѕ': 'S', // U+0455 / U+0405
|
||||
'ο': 'o', 'Ο': 'O', // U+03BF / U+039F (Greek)
|
||||
'α': 'a', // U+03B1
|
||||
};
|
||||
const HOMOGLYPH_RE = new RegExp(Object.keys(HOMOGLYPH_TO_ASCII).join('|'), 'g');
|
||||
|
||||
function decodeHtmlEntities(s: string): string {
|
||||
return s
|
||||
// Numeric decimal: j → 'j'
|
||||
.replace(/&#(\d+);?/g, (_m, code) => {
|
||||
const n = parseInt(code, 10);
|
||||
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
|
||||
})
|
||||
// Numeric hex: j → 'j'
|
||||
.replace(/&#x([0-9a-fA-F]+);?/g, (_m, code) => {
|
||||
const n = parseInt(code, 16);
|
||||
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
|
||||
})
|
||||
// Häufige Named-Entities (bewusst klein gehalten – wir wollen nur
|
||||
// XSS-relevante Bypässe verhindern, nicht jeden Entity-Sonderfall).
|
||||
// & ZULETZT, sonst doppel-dekodiert (`&lt;` → `<`).
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
@@ -274,16 +305,69 @@ function decodeHtmlEntities(s: string): string {
|
||||
.replace(/&/gi, '&');
|
||||
}
|
||||
|
||||
// Percent-decoded String. Iterativ bis stabil – ein Angreifer kann
|
||||
// "java%2573cript:" schreiben (`%25` ist `%`), das nach einer
|
||||
// Iteration zu "java%73cript:" wird, was wiederum zu "javascript:".
|
||||
// Max 5 Iterationen reichen für realistische Verschachtelungen.
|
||||
// `decodeURIComponent` würde bei ungültigen Sequenzen werfen, deshalb
|
||||
// machen wir es per Regex.
|
||||
function percentDecode(s: string): string {
|
||||
let prev = s;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const next = prev.replace(/%([0-9A-Fa-f]{2})/g, (_m, hex) =>
|
||||
String.fromCharCode(parseInt(hex, 16)),
|
||||
);
|
||||
if (next === prev) return next;
|
||||
prev = next;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strikte Email-Validierung. Wichtig vor allem für **SMTP-Header-
|
||||
* Injection**: ein gespeicherter Wert wie `test@x.de\nBcc:attacker@evil.de`
|
||||
* würde, wenn er in einen To-Header geschrieben wird, einen zusätzlichen
|
||||
* Bcc-Empfänger einschleusen (Pentest 29.4). Wir verbieten daher:
|
||||
* - Whitespace, Newlines, Tabs, Steuerzeichen
|
||||
* - mehr als ein `@`
|
||||
* - Domain ohne Punkt
|
||||
* - Länge > 254 (RFC 5321)
|
||||
* Format: `local@domain.tld`, local 1–64 ASCII, domain DNS-konform.
|
||||
*
|
||||
* `null`/leer ist erlaubt (Email ist oft optional). Aufrufer entscheidet,
|
||||
* ob `null` ok ist.
|
||||
*/
|
||||
export function isValidEmail(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === '') return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
if (value.length > 254) return false;
|
||||
// Newline/Tab/Steuerzeichen explizit ablehnen – das ist der
|
||||
// Header-Injection-Vektor.
|
||||
if (/[\r\n\t\0\v\f]/.test(value)) return false;
|
||||
// Basic RFC-5322-ähnlich, ohne quoted-string-Local-Part.
|
||||
// Local-Part: 1–64 Zeichen aus [a-z0-9._%+-], muss nicht mit Punkt
|
||||
// beginnen/enden. Domain: Labels mit Punkten, TLD mind. 2 Zeichen.
|
||||
return /^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(value);
|
||||
}
|
||||
|
||||
export function stripHtml(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
return decodeHtmlEntities(value)
|
||||
let s = value;
|
||||
// 1) Unicode-Steuerzeichen raus, sonst zerteilen sie Regex-Matches.
|
||||
s = s.replace(ZERO_WIDTH_CHARS, '');
|
||||
// 2) Percent-Encoding auflösen (iterativ bis stabil).
|
||||
s = percentDecode(s);
|
||||
// 3) Homoglyphe normalisieren, damit "jаvascript:" zu "javascript:" wird.
|
||||
s = s.replace(HOMOGLYPH_RE, (m) => HOMOGLYPH_TO_ASCII[m] || m);
|
||||
// 4) HTML-Entities dekodieren.
|
||||
s = decodeHtmlEntities(s);
|
||||
// 5) Tags + dangerous Schemes strippen.
|
||||
s = s
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||
// Schema durch harmloses Token ersetzen – komplette Entfernung
|
||||
// könnte legitimen Text wie "Java Script :)" verändern, dieses
|
||||
// Pattern matcht nur das Schema selbst.
|
||||
.replace(DANGEROUS_URI_SCHEMES, 'blocked:');
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user