Security-Hardening Runde 15: Pentest Runde 12 Folge-Fixes

M2-Reste – XSS-Strings + Mass-Assignment-Settings noch in DB:
Idempotentes Cleanup-Script prisma/cleanup-xss-and-mass-assignment.ts.
Strippt HTML aus Customer/User-String-Feldern, entfernt AppSettings
ohne Whitelist-Eintrag. Wird im entrypoint.sh nach Migrations + Seed
einmalig pro Container-Start ausgeführt.

User-Update + password-Feld:
password aus USER_UPDATABLE_FIELDS raus (CREATE behält es), neuer
dedizierter Endpoint POST /api/users/:id/password mit Audit-Log
"Passwort … durch Admin gesetzt" und Komplexitäts-Check.

JS-Runtime-Fehler-Leak:
ORM_LEAK_PATTERNS um TypeError/ReferenceError/SyntaxError/RangeError +
"Cannot read properties of undefined/null" + "is not a function/
defined" erweitert. Greift im globalen res.json()-Wrapper.

POST /contracts substring-Crash:
Controller validiert type/customerId, sonst 400. generateContractNumber
fängt nullish type ab (Fallback "CON").

Seed-Admin-Passwort:
Default "admin" verletzte 12-Zeichen-Policy. Jetzt 16-char
Zufallspasswort (alle 4 Klassen garantiert via Fisher-Yates) oder per
SEED_ADMIN_PASSWORD-ENV überschreibbar. BCRYPT-Cost 12 (war 10).
Passwort wird einmalig in stdout ausgegeben mit Warnung.

AppSettings-Whitelist: companyName + defaultEmailDomain ergänzt
(kamen aus seed.ts, in 1. Whitelist vergessen).

Live-verifiziert:
- POST /contracts {} → 400 "Vertrags-Typ erforderlich" (vorher
  TypeError-Stack)
- PUT /users/6 {password:"HackerPW2026!"} → 200 aber Login mit altem
  PW geht weiter
- POST /users/6/password mit "kurz" → 400 mit Komplexitäts-Fehlern
- Cleanup-Script: planted XSS bereinigt, hackerSetting+debugMode
  entfernt, idempotenter Re-Lauf

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:09:13 +02:00
parent d545790a69
commit cf8c6c84c2
11 changed files with 254 additions and 17 deletions
@@ -0,0 +1,92 @@
/**
* 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}`);
}
async function cleanupAppSettings() {
const settings = await prisma.appSetting.findMany();
const removed: string[] = [];
for (const s of settings) {
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
removed.push(s.key);
await prisma.appSetting.delete({ where: { key: s.key } });
}
}
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
}
async function main() {
console.log('=== Cleanup: XSS-Reste + Mass-Assignment-AppSettings ===');
await cleanupXss();
await cleanupAppSettings();
console.log('=== Fertig. ===');
}
main()
.catch((e) => {
console.error('Cleanup fehlgeschlagen:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+41 -3
View File
@@ -221,8 +221,37 @@ async function main() {
console.log('Roles created');
// Create admin user
const hashedPassword = await bcrypt.hash('admin', 10);
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
// Komplexitätspolicy). Stattdessen:
// - SEED_ADMIN_PASSWORD-ENV → wird verwendet (z.B. via docker-compose env)
// - sonst → zufälliges 16-Zeichen-Passwort, wird ein einziges Mal beim
// Seed in stdout ausgegeben. Wer das Log nicht sieht, muss
// Passwort-vergessen-Flow nutzen.
// Hash-Cost: 12 (OWASP 2026), nicht mehr 10.
function generateInitialPassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const special = '!@#$%&*+=?';
const all = upper + lower + digits + special;
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
// mind. einen aus jeder Klasse + Rest zufällig
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
for (let i = chars.length; i < 16; i++) chars.push(pick(all));
// Fisher-Yates Shuffle (sonst stehen die garantierten Klassen-Zeichen am Anfang)
for (let i = chars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
const envPassword = process.env.SEED_ADMIN_PASSWORD;
const adminPlainPassword = envPassword && envPassword.length >= 12
? envPassword
: generateInitialPassword();
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
const adminUser = await prisma.user.upsert({
where: { email: 'admin@admin.com' },
@@ -238,7 +267,16 @@ async function main() {
},
});
console.log('Admin user created: admin@admin.com / admin');
console.log('========================================================');
console.log(' Admin-User: admin@admin.com');
if (envPassword) {
console.log(' Passwort: aus SEED_ADMIN_PASSWORD');
} else {
console.log(` Initial-Passwort: ${adminPlainPassword}`);
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
console.log(' Bitte sofort nach dem ersten Login ändern.');
}
console.log('========================================================');
// Create some sales platforms
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
@@ -111,6 +111,17 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
export async function createContract(req: Request, res: Response): Promise<void> {
try {
// Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
// Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
const body = (req.body || {}) as Record<string, unknown>;
if (!body.type || typeof body.type !== 'string') {
res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse);
return;
}
if (!body.customerId || typeof body.customerId !== 'number') {
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
return;
}
const contract = await contractService.createContract(req.body);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
+42 -10
View File
@@ -82,17 +82,10 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
// password ist NICHT in der Whitelist generisches Update darf kein
// Passwort setzen. Dafür gibt es POST /users/:id/password mit eigenem
// Audit-Eintrag (Pentest Runde 12, MITTEL).
const data = pickUserUpdate(req.body);
if ((data as any)?.password) {
const c = validatePasswordComplexity((data as any).password);
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
}
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
@@ -155,6 +148,45 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
}
}
// Admin setzt das Passwort eines anderen Users zurück. Separat vom
// generischen Update damit der Vorgang explizit auditiert wird und nicht
// versehentlich über Mass-Assignment passieren kann.
// Pentest Runde 12 (2026-05-18) MITTEL.
export async function setUserPassword(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
const { password } = req.body || {};
if (!password || typeof password !== 'string') {
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
return;
}
const c = validatePasswordComplexity(password);
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
const user = await userService.updateUser(userId, { password } as any);
if (!user) {
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
return;
}
await logChange({
req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(),
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt`,
});
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
} as ApiResponse);
}
}
export async function deleteUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
+10
View File
@@ -273,6 +273,16 @@ const ORM_LEAK_PATTERNS: RegExp[] = [
/PrismaClient/i,
/^\s*at\s+[A-Za-z]+\s+\(/m, // Stack-Frame
/at\s+[A-Za-z][\w.<>]*\s*\([^)]*:\d+:\d+\)/, // file:line:col
// JS-Runtime-Fehler Pentest Runde 12 (2026-05-18): "Cannot read
// properties of undefined (reading 'substring')" leakte aus POST
// /contracts. Solche Texte verraten Implementierungs-Details.
/^TypeError\b/i,
/^ReferenceError\b/i,
/^SyntaxError\b/i,
/^RangeError\b/i,
/Cannot read propert(y|ies) of (undefined|null)/i,
/is not a function/i,
/is not defined$/i,
];
function sanitizeErrorString(s: string): string {
if (!s) return s;
+2
View File
@@ -10,6 +10,8 @@ router.post('/', authenticate, requirePermission('users:create'), userController
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser);
// Passwort-Reset durch Admin dedizierter Endpoint (Pentest Runde 12)
router.post('/:id/password', authenticate, requirePermission('users:update'), userController.setUserPassword);
// Roles
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
@@ -25,6 +25,8 @@ export const ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
'monitoringAlertEmail',
'monitoringDigestEnabled',
'monitoringLastDigestAt',
'companyName',
'defaultEmailDomain',
]);
export function isAllowedSettingKey(key: string): boolean {
+6 -2
View File
@@ -4,8 +4,12 @@ export function generateCustomerNumber(): string {
return `K${timestamp}${random}`;
}
export function generateContractNumber(type: string): string {
const prefix = type.substring(0, 3).toUpperCase();
export function generateContractNumber(type: string | null | undefined): string {
// Defensiv: ohne validen Type-String fällt der Prefix auf "CON" zurück.
// Pentest Runde 12: POST /contracts ohne `type` warf
// "Cannot read properties of undefined (reading 'substring')".
const safeType = (typeof type === 'string' && type.length > 0) ? type : 'CON';
const prefix = safeType.substring(0, 3).toUpperCase();
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 5).toUpperCase();
return `${prefix}-${timestamp}${random}`;
+9 -2
View File
@@ -204,7 +204,6 @@ const USER_UPDATABLE_FIELDS = [
'telegramUsername',
'signalNumber',
'roleIds',
'password', // nur Admin, wird im Service gehashed
// hasGdprAccess + hasDeveloperAccess sind keine User-Spalten der Service
// mappt sie auf die versteckten Rollen DSGVO/Developer (siehe
// setUserGdprAccess / setUserDeveloperAccess). Müssen aber auf der Whitelist
@@ -212,9 +211,17 @@ const USER_UPDATABLE_FIELDS = [
'hasGdprAccess',
'hasDeveloperAccess',
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
// Nicht: password wird über dedizierten Endpoint POST /users/:id/password
// gesetzt (Pentest Runde 12 (2026-05-18) MITTEL: generisches User-Update
// hatte password in der Whitelist, ein Admin konnte stillschweigend ohne
// dedizierten Audit-Trail Passwörter überschreiben).
] as const;
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
// Bei CREATE braucht's das initial-Passwort
const USER_CREATE_FIELDS = [
...USER_UPDATABLE_FIELDS,
'password',
] as const;
/**
* Strippt HTML-Tags und Script-/Style-Inhalt aus einem String, damit ein
+6
View File
@@ -29,6 +29,12 @@ if [ "$RUN_SEED" = "true" ]; then
npx tsx prisma/seed.ts
fi
# Einmal-Bereinigung für Pentest-Reste (Runde 12): XSS-Strings aus
# Customer/User-Feldern entfernen + unbekannte AppSettings löschen.
# Idempotent läuft bei jedem Container-Start ohne Risiko.
echo "Running data cleanup..."
npx tsx prisma/cleanup-xss-and-mass-assignment.ts || echo " (Cleanup übersprungen, nicht-kritisch)"
# Start the application
echo "Starting OpenCRM server..."
exec node dist/index.js
+33
View File
@@ -97,6 +97,39 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🚨 Pentest Runde 12 Folge-Fixes: XSS-Reste, User-PW-Endpoint, JS-Error-Leak, Seed-PW**
- **M2-Reste (XSS-Strings noch in DB)**: neues idempotentes Script
`prisma/cleanup-xss-and-mass-assignment.ts` läuft beim
Container-Start. Strippt HTML aus Customer/User-String-Feldern;
entfernt AppSettings, deren Key nicht in `ALLOWED_SETTING_KEYS`
steht. Mehrfacher Aufruf ändert nichts.
- **User-Update akzeptierte `password`-Feld**: stillschweigend ohne
dedizierten Audit-Eintrag. Jetzt: `password` aus
`USER_UPDATABLE_FIELDS` raus (CREATE behält es weiterhin); neuer
Endpoint `POST /api/users/:id/password` mit eigenem Audit-Log
"Passwort … durch Admin gesetzt", Komplexitäts-Check inklusive.
- **JS-Runtime-Fehler leakten weiter**: ORM-Leak-Patterns erweitert
um `TypeError`, `ReferenceError`, `SyntaxError`, `RangeError`,
"Cannot read propert(y|ies) of (undefined|null)", "is not a
function", "is not defined". Greift im globalen
`res.json()`-Wrapper.
- **POST /contracts substring-Crash**: defensiv `type` fehlt →
400 mit klarer Meldung; `generateContractNumber()` fängt auch
leere/null type ab (Fallback "CON").
- **Seed-Admin-Passwort "admin" verletzte Policy**: jetzt
16-Zeichen-Zufallspasswort beim Seed (mit allen 4 Klassen
garantiert) oder via `SEED_ADMIN_PASSWORD`-ENV überschreibbar;
BCRYPT-Cost auf 12 (war 10); Passwort wird **einmalig** beim
Seed in stdout ausgegeben mit Warnung.
- **AppSettings-Whitelist ergänzt**: `companyName`,
`defaultEmailDomain` (kommen aus seed.ts, waren in der ersten
Whitelist vergessen).
- **Live-verifiziert**: POST /contracts {} → klare 400 statt
JS-Crash; PUT /users/6 {password:...} ignoriert (Login mit
altem PW geht weiter); POST /users/6/password mit kurz → 400;
Cleanup-Script: 1 Customer bereinigt, 2 unbekannte AppSettings
entfernt (hackerSetting, debugMode), Re-Lauf → 0 Änderungen.
- [x] **🚨 Pentest Runde 11 Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip**
- **C2 KRITISCH Factory Reset ohne Bestätigung**:
Eingeloggter Admin konnte mit leerem oder beliebigem Body