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:
"j​av​ascript:" 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:
2026-05-20 18:47:44 +02:00
parent 65ec07e274
commit 9cf8c505af
6 changed files with 257 additions and 31 deletions
@@ -11,6 +11,7 @@ import {
sanitizeCustomerStrict,
pickCustomerCreate,
pickCustomerUpdate,
isValidEmail,
} from '../utils/sanitize.js';
import {
canAccessMeter,
@@ -79,6 +80,16 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen
const data: any = pickCustomerCreate(req.body);
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
if (data.email && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
// Convert birthDate string to Date if present
if (data.birthDate) {
data.birthDate = new Date(data.birthDate);
@@ -110,6 +121,15 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
try {
const customerId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
if (req.body?.email && !isValidEmail(req.body.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
const data: any = pickCustomerUpdate(req.body);
// Vorherigen Stand laden für Audit
@@ -937,6 +957,11 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
return;
}
const { portalEnabled, portalEmail } = body;
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
if (portalEmail && !isValidEmail(portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({
+53 -8
View File
@@ -891,26 +891,71 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
}
// Magic-Byte-Check: multer prüft nur den client-gemeldeten MIME-Type,
// ein Angreifer kann beliebige Daten als "application/pdf" hochladen.
// PDF beginnt mit "%PDF-" (0x25 0x50 0x44 0x46 0x2D). Wenn nicht,
// gleich wegwerfen. (Pentest 2026-05-20 LOW 28.3)
// Strukturelle PDF-Validierung: multer prüft nur den client-gemeldeten
// MIME-Type, ein Angreifer kann beliebige Daten als "application/pdf"
// hochladen. Wir verlangen:
// 1) Magic-Bytes "%PDF-" am Anfang
// 2) "%%EOF"-Marker in den letzten 1024 Bytes (Standard-PDF-Ende)
// 3) keinen Shebang ("#!") und kein "<script"/"<?php" in den
// ersten 4 KB (Pentest 28.3 Partial: "%PDF-1.4\n#!/bin/bash"
// passierte die reine Magic-Byte-Prüfung).
// Wer trotzdem eine PDF mit eingebettetem JS hochlädt, bekommt das
// hier nicht erkannt aber das ist Adobe-Acrobat-Risiko und nicht
// mehr ein CRM-Backend-Bug. Hier geht's um simple File-Type-Spoofs.
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
try {
const stat = fs.statSync(req.file.path);
const fd = fs.openSync(req.file.path, 'r');
// Header
const head = Buffer.alloc(5);
fs.readSync(fd, head, 0, 5, 0);
fs.closeSync(fd);
if (!head.equals(PDF_MAGIC)) {
// Hochgeladene Nicht-PDF-Datei sofort wieder löschen, sonst
// bleibt der Müll im uploads/-Volume.
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).',
});
}
} catch (e) {
// Erste 4 KB scannen auf verbotene Marker (Shell-Script,
// HTML/PHP-Payload). Ein echtes PDF enthält am Anfang nur
// Binärdaten + ein paar ASCII-Marker, "#!" / "<script" sind
// klare Spoof-Indikatoren.
const headSize = Math.min(stat.size, 4096);
const headBuf = Buffer.alloc(headSize);
fs.readSync(fd, headBuf, 0, headSize, 0);
const headStr = headBuf.toString('latin1').toLowerCase();
const forbidden = ['#!/', '<script', '<?php', '<%', 'mz']; // last = PE/Windows exe
const hit = forbidden.find((m) => headStr.includes(m));
if (hit) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: `Datei enthält verdächtiges Payload-Pattern ("${hit}").`,
});
}
// EOF-Marker in den letzten 1 KB. Strikt PDF/A wäre genau am
// Dateiende, aber viele Tools schreiben Whitespace/Newlines
// nach %%EOF, deshalb prüfen wir das letzte KB.
if (stat.size >= 5) {
const tailSize = Math.min(stat.size, 1024);
const tailBuf = Buffer.alloc(tailSize);
fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
if (!tailBuf.toString('latin1').includes('%%EOF')) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (EOF-Marker fehlt).',
});
}
}
fs.closeSync(fd);
} catch (_e) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
+14 -1
View File
@@ -18,7 +18,20 @@ export async function getProviders(req: Request, res: Response): Promise<void> {
export async function getProvider(req: Request, res: Response): Promise<void> {
try {
const provider = await providerService.getProviderById(parseInt(req.params.id));
// `req.params.id` ist Pfad-Segment bei /api/providers/email landet
// hier der String "email", den parseInt zu NaN macht. Ohne Validierung
// fuhr Prisma dann gegen `WHERE id = NaN` und warf 500.
// Pentest 2026-05-20, 29.5: explizit 404 statt 500. Andere Sub-Routes
// wie /api/providers/<id>/tariffs greifen weiter wie gehabt.
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
res.status(404).json({
success: false,
error: 'Anbieter nicht gefunden',
} as ApiResponse);
return;
}
const provider = await providerService.getProviderById(id);
if (!provider) {
res.status(404).json({
success: false,
+13 -1
View File
@@ -3,7 +3,7 @@ import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Users
@@ -53,6 +53,12 @@ export async function createUser(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserCreate(req.body) as any;
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
// (Pentest 29.4 SMTP-Header-Injection).
if (!isValidEmail(data?.email) || !data?.email) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (data?.password) {
const c = validatePasswordComplexity(data.password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
@@ -108,6 +114,12 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
}
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body);
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
if (data?.email !== undefined && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
+105 -21
View File
@@ -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
* `&#106;avascript:` und `&#60;script&#62;` 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+200BU+200F (ZWSP, ZWNJ, ZWJ, LRM, RLM), U+202AU+202E (Embedding/
// Override), U+2060U+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: &#106; → 'j'
.replace(/&#(\d+);?/g, (_m, code) => {
const n = parseInt(code, 10);
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
})
// Numeric hex: &#x6A; → '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).
// &amp; ZULETZT, sonst doppel-dekodiert (`&amp;lt;` → `<`).
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
@@ -274,16 +305,69 @@ function decodeHtmlEntities(s: string): string {
.replace(/&amp;/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 164 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: 164 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;
}
/**