8e48d3b432
27.1 Path-Traversal-Strings in DB:
- cleanupConsents validierte documentPath zuvor nur per stripHtml,
ließ "../../../etc/passwd" durch. Neuer isValidDocumentPath-Check
akzeptiert nur "/uploads/<safe>", alles andere → NULL.
- cleanupDocumentPaths scannt fünf weitere Tabellen (BankCard,
IdentityDocument, Invoice, RepresentativeAuthorization nullable;
ContractDocument NOT NULL → nur Report).
Orphaned User:
- reportOrphanedUsers warnt beim Container-Start vor User ohne
Rollenzuordnung (im Permission-System unsichtbar). Löschen nicht
automatisch wegen False-Positive-Risiko.
Seed-PW-Policy:
- generateInitialPassword() nutzte Math.random() (vorhersagbar).
Jetzt crypto.randomInt() für Pick + Fisher-Yates-Shuffle.
PUT /users/:id mit permissions / password:
- Vorher silent-drop durch Whitelist + HTTP 200, Caller glaubte
faelschlich, Werte waeren uebernommen. Jetzt HTTP 400 mit
konkreter Hilfe-Message.
/api/health ohne Auth:
- Pentest-Befund INFO: bewusst so, Container-Healthcheck und
Reverse-Proxy pingen ohne Bearer-Token. Antwort liefert nur
{status,timestamp} – keine Version, kein DB-Status, kein
Info-Leak. Comment im Code dokumentiert die Entscheidung.
Live-verifiziert auf dev: alle fuenf Findings durchgetestet,
jeweils mit dirty Input → erwartete Sanitization/Antwort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
/**
|
||
* Einmal-Bereinigung für Pentest-Reste (Runde 12 / 2026-05-18):
|
||
*
|
||
* 1. HTML-Tags aus Customer/User-Stringfeldern strippen (M2-Stored-XSS-Reste)
|
||
* 2. Unbekannte App-Settings entfernen, die durch Mass-Assignment in die DB
|
||
* gerutscht sind, BEVOR die Whitelist eingezogen wurde (M1-Reste).
|
||
*
|
||
* Idempotent: wenn nichts zu tun ist, ändert sich nichts. Bei Bedarf
|
||
* mehrfach aufrufbar.
|
||
*/
|
||
import prisma from '../src/lib/prisma.js';
|
||
import { stripHtml } from '../src/utils/sanitize.js';
|
||
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
|
||
|
||
const CUSTOMER_STRING_FIELDS = [
|
||
'salutation', 'firstName', 'lastName', 'companyName',
|
||
'birthPlace', 'email', 'phone', 'mobile',
|
||
'taxNumber', 'commercialRegisterNumber', 'notes',
|
||
];
|
||
|
||
const USER_STRING_FIELDS = [
|
||
'firstName', 'lastName', 'email',
|
||
'whatsappNumber', 'telegramUsername', 'signalNumber',
|
||
];
|
||
|
||
async function cleanupXss() {
|
||
const customers = await prisma.customer.findMany();
|
||
let touched = 0;
|
||
for (const c of customers) {
|
||
const updates: Record<string, string> = {};
|
||
for (const field of CUSTOMER_STRING_FIELDS) {
|
||
const v = (c as any)[field];
|
||
if (typeof v === 'string') {
|
||
const cleaned = stripHtml(v) as string;
|
||
if (cleaned !== v) updates[field] = cleaned;
|
||
}
|
||
}
|
||
if (Object.keys(updates).length > 0) {
|
||
console.log(` Customer #${c.id}: bereinigt:`, Object.keys(updates).join(', '));
|
||
await prisma.customer.update({ where: { id: c.id }, data: updates });
|
||
touched++;
|
||
}
|
||
}
|
||
console.log(` → Customer bereinigt: ${touched}`);
|
||
|
||
const users = await prisma.user.findMany();
|
||
let userTouched = 0;
|
||
for (const u of users) {
|
||
const updates: Record<string, string> = {};
|
||
for (const field of USER_STRING_FIELDS) {
|
||
const v = (u as any)[field];
|
||
if (typeof v === 'string') {
|
||
const cleaned = stripHtml(v) as string;
|
||
if (cleaned !== v) updates[field] = cleaned;
|
||
}
|
||
}
|
||
if (Object.keys(updates).length > 0) {
|
||
console.log(` User #${u.id}: bereinigt:`, Object.keys(updates).join(', '));
|
||
await prisma.user.update({ where: { id: u.id }, data: updates });
|
||
userTouched++;
|
||
}
|
||
}
|
||
console.log(` → User bereinigt: ${userTouched}`);
|
||
}
|
||
|
||
// HTML in Plain-Text-Settings strippen: WYSIWYG-Editoren liefern
|
||
// absichtlich HTML, alles andere (companyName, defaultEmailDomain, ...)
|
||
// muss reiner Text bleiben. Pentest 2026-05-19, MEDIUM.
|
||
const HTML_ALLOWED_SETTING_KEYS = new Set([
|
||
'authorizationTemplateHtml',
|
||
'imprintHtml',
|
||
'privacyPolicyHtml',
|
||
'websitePrivacyPolicyHtml',
|
||
]);
|
||
|
||
function stripHtmlString(s: string): string {
|
||
return s
|
||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||
.replace(/(?:javascript|data|vbscript)\s*:/gi, 'blocked:');
|
||
}
|
||
|
||
// Legitime CustomerConsent.source-Werte. Alles andere wird beim Cleanup
|
||
// auf 'unknown' normalisiert. Pentest 2026-05-20.
|
||
const ALLOWED_CONSENT_SOURCES = new Set([
|
||
'portal',
|
||
'public-link',
|
||
'telefon',
|
||
'papier',
|
||
'email',
|
||
'crm-backend',
|
||
]);
|
||
|
||
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
||
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
|
||
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
|
||
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
|
||
// raus (Pentest 2026-05-20 LOW 27.1).
|
||
function isValidDocumentPath(v: string | null | undefined): boolean {
|
||
if (!v) return true; // null/leer ist OK
|
||
if (v.includes('..')) return false;
|
||
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
||
if (/<[a-z!\/]/i.test(v)) return false; // HTML im Pfad
|
||
// erlaubt: "uploads/...", "/uploads/..."; keine Kontrollzeichen
|
||
return /^\/?uploads\/[A-Za-z0-9._\-\/]+$/.test(v);
|
||
}
|
||
|
||
async function cleanupConsents() {
|
||
// version + documentPath: HTML strippen (waren ohne Validierung).
|
||
// source: Whitelist erzwingen.
|
||
// documentPath zusätzlich gegen Pfad-Traversal absichern (27.1).
|
||
let versionStripped = 0;
|
||
let pathNulled = 0;
|
||
let sourceFixed = 0;
|
||
const consents = await prisma.customerConsent.findMany({
|
||
select: { id: true, source: true, documentPath: true, version: true },
|
||
});
|
||
for (const c of consents) {
|
||
const data: Record<string, string | null> = {};
|
||
if (c.version && c.version !== stripHtmlString(c.version)) {
|
||
data.version = stripHtmlString(c.version);
|
||
versionStripped++;
|
||
}
|
||
if (c.documentPath && !isValidDocumentPath(c.documentPath)) {
|
||
// ".../etc/passwd", "<script>", "javascript:..." etc. → NULL.
|
||
// Legitime Uploads bleiben unberührt (siehe isValidDocumentPath).
|
||
data.documentPath = null;
|
||
pathNulled++;
|
||
}
|
||
if (c.source && !ALLOWED_CONSENT_SOURCES.has(c.source)) {
|
||
data.source = 'unknown';
|
||
sourceFixed++;
|
||
}
|
||
if (Object.keys(data).length > 0) {
|
||
await prisma.customerConsent.update({ where: { id: c.id }, data });
|
||
}
|
||
}
|
||
console.log(
|
||
` → Consent bereinigt: version-stripped=${versionStripped}, ` +
|
||
`documentPath-genullt=${pathNulled}, source-whitelist=${sourceFixed}`,
|
||
);
|
||
}
|
||
|
||
// documentPath in den weiteren Tabellen prüfen. Schreibend wird er
|
||
// server-seitig vom multer-Upload erzeugt – falls dort doch mal ein
|
||
// dreckiger Wert reingerutscht ist (z.B. aus einem importierten Backup
|
||
// vor unseren Sanitization-Fixes), nullen wir ihn hier raus.
|
||
// ContractDocument hat documentPath NOT NULL → wir berichten dort nur,
|
||
// löschen aber nicht (Records müssten manuell angeschaut werden).
|
||
async function cleanupDocumentPaths() {
|
||
const findings: { table: string; id: number; value: string }[] = [];
|
||
|
||
const optional: Array<{
|
||
label: string;
|
||
fetch: () => Promise<{ id: number; documentPath: string | null }[]>;
|
||
update: (id: number) => Promise<unknown>;
|
||
}> = [
|
||
{
|
||
label: 'BankCard',
|
||
fetch: () => prisma.bankCard.findMany({ select: { id: true, documentPath: true } }),
|
||
update: (id) => prisma.bankCard.update({ where: { id }, data: { documentPath: null } }),
|
||
},
|
||
{
|
||
label: 'IdentityDocument',
|
||
fetch: () => prisma.identityDocument.findMany({ select: { id: true, documentPath: true } }),
|
||
update: (id) => prisma.identityDocument.update({ where: { id }, data: { documentPath: null } }),
|
||
},
|
||
{
|
||
label: 'Invoice',
|
||
fetch: () => prisma.invoice.findMany({ select: { id: true, documentPath: true } }),
|
||
update: (id) => prisma.invoice.update({ where: { id }, data: { documentPath: null } }),
|
||
},
|
||
{
|
||
label: 'RepresentativeAuthorization',
|
||
fetch: () => prisma.representativeAuthorization.findMany({
|
||
select: { id: true, documentPath: true },
|
||
}),
|
||
update: (id) => prisma.representativeAuthorization.update({
|
||
where: { id }, data: { documentPath: null },
|
||
}),
|
||
},
|
||
];
|
||
|
||
let nulled = 0;
|
||
for (const t of optional) {
|
||
const rows = await t.fetch();
|
||
for (const r of rows) {
|
||
if (r.documentPath && !isValidDocumentPath(r.documentPath)) {
|
||
findings.push({ table: t.label, id: r.id, value: r.documentPath.slice(0, 80) });
|
||
await t.update(r.id);
|
||
nulled++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ContractDocument: documentPath ist NOT NULL → wir berichten nur.
|
||
const contractDocs = await prisma.contractDocument.findMany({
|
||
select: { id: true, documentPath: true },
|
||
});
|
||
let contractDocsDirty = 0;
|
||
for (const d of contractDocs) {
|
||
if (!isValidDocumentPath(d.documentPath)) {
|
||
findings.push({ table: 'ContractDocument', id: d.id, value: d.documentPath.slice(0, 80) });
|
||
contractDocsDirty++;
|
||
}
|
||
}
|
||
|
||
console.log(` → documentPath bereinigt: ${nulled} genullt, ${contractDocsDirty} ContractDocument-Records auffällig (NOT NULL, manuell prüfen)`);
|
||
for (const f of findings.slice(0, 10)) {
|
||
console.log(` [${f.table}#${f.id}] "${f.value}"`);
|
||
}
|
||
}
|
||
|
||
async function reportOrphanedUsers() {
|
||
// User ohne jegliche Rollenzuordnung können sich zwar einloggen, sind aber
|
||
// im Permission-System unsichtbar. Meist Überrest von gescheiterten Seeds
|
||
// oder manuellen DB-Edits. Wir löschen NICHT (könnte legitime
|
||
// Spezial-User treffen) – nur warnen.
|
||
const orphans = await prisma.user.findMany({
|
||
where: { roles: { none: {} } },
|
||
select: { id: true, email: true, createdAt: true },
|
||
});
|
||
if (orphans.length === 0) {
|
||
console.log(' → Keine User ohne Rollenzuordnung.');
|
||
return;
|
||
}
|
||
console.log(` ⚠️ ${orphans.length} User ohne Rollenzuordnung:`);
|
||
for (const u of orphans.slice(0, 10)) {
|
||
console.log(` [User#${u.id}] ${u.email} (created ${u.createdAt.toISOString()})`);
|
||
}
|
||
console.log(' → Rolle zuweisen oder User löschen.');
|
||
}
|
||
|
||
async function cleanupAppSettings() {
|
||
const settings = await prisma.appSetting.findMany();
|
||
const removed: string[] = [];
|
||
let stripped = 0;
|
||
for (const s of settings) {
|
||
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
|
||
removed.push(s.key);
|
||
await prisma.appSetting.delete({ where: { key: s.key } });
|
||
continue;
|
||
}
|
||
if (!HTML_ALLOWED_SETTING_KEYS.has(s.key)) {
|
||
const cleaned = stripHtmlString(s.value);
|
||
if (cleaned !== s.value) {
|
||
await prisma.appSetting.update({ where: { key: s.key }, data: { value: cleaned } });
|
||
stripped++;
|
||
}
|
||
}
|
||
}
|
||
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
|
||
if (stripped > 0) {
|
||
console.log(` → AppSettings HTML-gestrippt: ${stripped}`);
|
||
}
|
||
}
|
||
|
||
// Pattern, die auf typische Pentest-/Test-Daten hindeuten. Bewusst eng
|
||
// gefasst – legitime Kunden mit "Hacker" als Nachnamen sollen nicht
|
||
// fälschlich getroffen werden (gibt's reichlich, gerade hier).
|
||
// Konkret weggelassen: `^hacker@` würde Verwandte/Kunden mit
|
||
// `hacker@familie-hacker.de` o.ä. fängen.
|
||
const PENTEST_MARKERS = [
|
||
/@evil\./i,
|
||
/^attacker@/i,
|
||
/^pentest@/i,
|
||
/<script\b/i, // unverwechselbarer XSS-Marker
|
||
/\bonerror\s*=/i, // <img onerror=…>
|
||
/javascript:/i, // javascript:-URL
|
||
/'\s*OR\s*'1'\s*=\s*'1/i, // SQL-Injection
|
||
/\.\.\/.*etc\/passwd/i, // Path-Traversal
|
||
];
|
||
|
||
function looksLikePentestData(value: unknown): boolean {
|
||
if (typeof value !== 'string') return false;
|
||
return PENTEST_MARKERS.some((re) => re.test(value));
|
||
}
|
||
|
||
async function findOrPurgePentestRecords() {
|
||
const purge = process.env.CLEANUP_PURGE_PENTEST === 'true';
|
||
const suspect: Array<{ kind: string; id: number; reason: string }> = [];
|
||
|
||
const customers = await prisma.customer.findMany();
|
||
for (const c of customers) {
|
||
for (const f of ['email', 'phone', 'mobile', 'firstName', 'lastName', 'companyName', 'notes']) {
|
||
if (looksLikePentestData((c as any)[f])) {
|
||
suspect.push({ kind: 'Customer', id: c.id, reason: `${f}=${JSON.stringify((c as any)[f]).slice(0, 60)}` });
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const users = await prisma.user.findMany();
|
||
for (const u of users) {
|
||
for (const f of ['email', 'firstName', 'lastName']) {
|
||
if (looksLikePentestData((u as any)[f])) {
|
||
suspect.push({ kind: 'User', id: u.id, reason: `${f}=${JSON.stringify((u as any)[f]).slice(0, 60)}` });
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (suspect.length === 0) {
|
||
console.log(' → Keine Pentest-Marker in Customer/User-Records gefunden.');
|
||
return;
|
||
}
|
||
|
||
console.log(` → ${suspect.length} verdächtige Records (Pentest-Marker):`);
|
||
for (const s of suspect) {
|
||
console.log(` [${s.kind}#${s.id}] ${s.reason}`);
|
||
}
|
||
|
||
if (!purge) {
|
||
console.log(' ℹ️ Zum Löschen Container mit CLEANUP_PURGE_PENTEST=true neu starten,');
|
||
console.log(' oder Records manuell über adminer entfernen.');
|
||
return;
|
||
}
|
||
|
||
for (const s of suspect) {
|
||
if (s.kind === 'Customer') {
|
||
await prisma.customer.delete({ where: { id: s.id } }).catch((e: any) => {
|
||
console.log(` [Customer#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
|
||
});
|
||
} else if (s.kind === 'User') {
|
||
await prisma.user.delete({ where: { id: s.id } }).catch((e: any) => {
|
||
console.log(` [User#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
|
||
});
|
||
}
|
||
}
|
||
console.log(` → ${suspect.length} verdächtige Records gelöscht.`);
|
||
}
|
||
|
||
async function main() {
|
||
console.log('=== Cleanup: XSS-Reste + Mass-Assignment-AppSettings ===');
|
||
await cleanupXss();
|
||
await cleanupAppSettings();
|
||
await cleanupConsents();
|
||
await cleanupDocumentPaths();
|
||
await reportOrphanedUsers();
|
||
await findOrPurgePentestRecords();
|
||
console.log('=== Fertig. ===');
|
||
}
|
||
|
||
main()
|
||
.catch((e) => {
|
||
console.error('Cleanup fehlgeschlagen:', e);
|
||
process.exit(1);
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|