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:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user