Files
opencrm/backend/src/controllers/user.controller.ts
T
duffyduck 5fa9d4d4f3 Pentest 60.3 MEDIUM: sanitizePhoneField auf Customer + User-Felder ausweiten
Der Fix aus 51.3 deckte nur Contract-PhoneNumber-Felder ab. CRLF in
`Customer.phone`, `Customer.mobile` und (im selben Code-Pfad)
`User.whatsappNumber`, `User.signalNumber` ging weiter durch –
pickCustomerUpdate / pickUserUpdate macht nur stripHtml, das filtert
keine Control-Chars.

- sanitizePhoneField von contract.service nach utils/sanitize gezogen
  und EXPORT, damit alle Stellen denselben Allowlist-Check
  (/^[0-9+\-/(). ]{0,40}$/) nutzen. Literales Space, NICHT \s.
- customer.controller updateCustomer + createCustomer: phone + mobile
  durch sanitizePhoneField → 400 bei CRLF/Control-Chars.
- user.controller updateUser + createUser: whatsappNumber +
  signalNumber analog.
- contract.service nutzt jetzt den importierten Helper (Lokale
  Kopie entfernt – Single Source of Truth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:40:40 +02:00

414 lines
16 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.
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Users
export async function getUsers(req: Request, res: Response): Promise<void> {
try {
const { search, isActive, roleId, page, limit } = req.query;
const result = await userService.getAllUsers({
search: search as string,
isActive: isActive !== undefined ? isActive === 'true' : undefined,
roleId: roleId ? parseInt(roleId as string) : undefined,
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
res.json({
success: true,
data: result.users,
pagination: result.pagination,
} as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Benutzer',
} as ApiResponse);
}
}
export async function getUser(req: Request, res: Response): Promise<void> {
try {
const user = await userService.getUserById(parseInt(req.params.id));
if (!user) {
res.status(404).json({
success: false,
error: 'Benutzer nicht gefunden',
} as ApiResponse);
return;
}
res.json({ success: true, data: user } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden des Benutzers',
} as ApiResponse);
}
}
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) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
}
// 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern.
try {
if ('whatsappNumber' in data) {
const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer');
data.whatsappNumber = cleaned ?? null;
}
if ('signalNumber' in data) {
const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer');
data.signalNumber = cleaned ?? null;
}
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
return;
}
const user = await userService.createUser(data);
await logChange({
req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(),
label: `Benutzer ${user.firstName} ${user.lastName} angelegt`,
});
res.status(201).json({ success: true, data: user } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers',
} as ApiResponse);
}
}
export async function updateUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
// `permissions` und `password` darf der generische Update nicht
// entgegennehmen. Vorher landeten sie auf dem Floor (Whitelist-Drop),
// der Caller bekam aber 200 zurück und glaubte fälschlich, die Werte
// wären übernommen worden. Stattdessen sofort 400, damit Tooling /
// Client den Fehler sieht. (Pentest 2026-05-20)
// - permissions kommen aus Rollen (PUT roleIds bzw. die DSGVO-/
// Developer-Checkboxen) und können nicht direkt am User hängen.
// - password wird über POST /users/:id/password gesetzt
// (eigene Komplexitäts-Validierung + Audit-Trail).
const body = req.body || {};
const forbidden = ['permissions', 'password', 'passwordHash'];
const offending = forbidden.filter((k) => k in body);
if (offending.length > 0) {
res.status(400).json({
success: false,
error: `Felder nicht erlaubt: ${offending.join(', ')}. ` +
(offending.includes('permissions')
? 'Permissions werden über roleIds / hasGdprAccess / hasDeveloperAccess gesteuert. '
: '') +
(offending.includes('password') || offending.includes('passwordHash')
? `Passwort über POST /users/${userId}/password setzen.`
: ''),
} as ApiResponse);
return;
}
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body) as Record<string, unknown>;
// 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;
}
// 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern.
try {
if ('whatsappNumber' in data) {
const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer');
data.whatsappNumber = cleaned ?? null;
}
if ('signalNumber' in data) {
const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer');
data.signalNumber = cleaned ?? null;
}
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
const beforeUser = await prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } },
});
const before = beforeUser
? {
...beforeUser,
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
}
: null;
const user = await userService.updateUser(userId, data as any);
if (user) {
// Audit: Geänderte Felder ermitteln und loggen
if (before) {
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
};
for (const [key, newVal] of Object.entries(data)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
const oldVal = (before as any)[key];
const norm = (v: unknown) => (v === null || v === undefined || v === '' ? null : v);
if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) {
const label = fieldLabels[key] || key;
const formatVal = (v: unknown) => {
if (v === null || v === undefined || v === '') return '-';
if (typeof v === 'boolean') return v ? 'Ja' : 'Nein';
return String(v);
};
changes[label] = { von: formatVal(oldVal), nach: formatVal(newVal) };
}
}
const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von}${c.nach}`).join(', ');
await logChange({
req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(),
label: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
details: Object.keys(changes).length > 0 ? changes : undefined,
});
} else {
await logChange({
req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(),
label: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
});
}
}
res.json({ success: true, data: user } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers',
} as ApiResponse);
}
}
// 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, currentPassword } = req.body || {};
if (!password || typeof password !== 'string') {
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
return;
}
// Pentest 47.3 (MEDIUM, 2026-06-01): Re-Auth verpflichtend.
// Ein gestohlener Admin-JWT reichte bisher, um Staff-Passwörter
// umzuschreiben. Jetzt muss der aufrufende Admin sein eigenes
// Passwort mitsenden CSRF/Token-Klau allein reicht nicht mehr.
const authReq = req as AuthRequest;
const callerId = authReq.user?.userId;
if (!callerId) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
if (!currentPassword || typeof currentPassword !== 'string') {
res.status(400).json({
success: false,
error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.',
} as ApiResponse);
return;
}
const caller = await prisma.user.findUnique({
where: { id: callerId },
select: { password: true },
});
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
const reAuthOk = await bcrypt.compare(currentPassword, callerHash);
if (!caller || !reAuthOk) {
res.status(403).json({
success: false,
error: 'Eigenes Passwort ist falsch.',
} as ApiResponse);
return;
}
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
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;
}
// Pentest 48.4 (INFO, 2026-06-01): Bestehende JWTs für den Ziel-User
// sofort invalidieren. Greift insbesondere bei Self-Reset (Admin setzt
// sich selbst zurück) der gestohlene Token wird damit ungültig,
// statt noch bis zum natürlichen Ablauf brauchbar zu bleiben.
// Die Middleware-Auth liest tokenInvalidatedAt und vergleicht gegen
// den `iat`-Claim des JWT.
await prisma.user.update({
where: { id: user.id },
data: { tokenInvalidatedAt: new Date() },
});
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 (Re-Auth bestätigt, Sessions invalidiert)`,
});
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);
const userBefore = await userService.getUserById(userId);
await userService.deleteUser(userId);
await logChange({
req, action: 'DELETE', resourceType: 'User',
resourceId: userId.toString(),
label: `Benutzer ${userBefore?.firstName || ''} ${userBefore?.lastName || ''} gelöscht`,
});
res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers',
} as ApiResponse);
}
}
// Roles
export async function getRoles(req: Request, res: Response): Promise<void> {
try {
const roles = await userService.getAllRoles();
res.json({ success: true, data: roles } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Rollen',
} as ApiResponse);
}
}
export async function getRole(req: Request, res: Response): Promise<void> {
try {
const role = await userService.getRoleById(parseInt(req.params.id));
if (!role) {
res.status(404).json({
success: false,
error: 'Rolle nicht gefunden',
} as ApiResponse);
return;
}
res.json({ success: true, data: role } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Rolle',
} as ApiResponse);
}
}
export async function createRole(req: Request, res: Response): Promise<void> {
try {
const role = await userService.createRole(req.body);
await logChange({
req, action: 'CREATE', resourceType: 'Role',
resourceId: role.id.toString(),
label: `Rolle ${role.name} angelegt`,
});
res.status(201).json({ success: true, data: role } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle',
} as ApiResponse);
}
}
export async function updateRole(req: Request, res: Response): Promise<void> {
try {
const role = await userService.updateRole(parseInt(req.params.id), req.body);
if (role) {
await logChange({
req, action: 'UPDATE', resourceType: 'Role',
resourceId: role.id.toString(),
label: `Rolle ${role.name} aktualisiert`,
});
}
res.json({ success: true, data: role } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle',
} as ApiResponse);
}
}
export async function deleteRole(req: Request, res: Response): Promise<void> {
try {
const roleId = parseInt(req.params.id);
const role = await userService.getRoleById(roleId);
await userService.deleteRole(roleId);
await logChange({
req, action: 'DELETE', resourceType: 'Role',
resourceId: roleId.toString(),
label: `Rolle ${role?.name || roleId} gelöscht`,
});
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle',
} as ApiResponse);
}
}
// Permissions
export async function getPermissions(req: Request, res: Response): Promise<void> {
try {
const permissions = await userService.getAllPermissions();
res.json({ success: true, data: permissions } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Berechtigungen',
} as ApiResponse);
}
}