Files
opencrm/backend/prisma/cleanup-xss-and-mass-assignment.ts
T
duffyduck 8e48d3b432 Pentest 2026-05-20 LOW/INFO Sammelfix
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>
2026-05-20 07:49:06 +02:00

352 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});