Files
opencrm/backend/src/routes/developer.routes.ts
T
duffyduck a7d12b8540 Security-Hardening Runde 7: Pentest Runde 3 (3 Findings)
KRITISCH – Privilege Escalation:
POST /api/developer/setup war ohne Auth erreichbar und konnte
developer:access der Admin-Rolle hinzufügen → volle DB-Kontrolle
via /developer/*-Routen. Endpoint ersatzlos entfernt; manuelles
Setzen geht über prisma/add-developer-permission.ts (CLI).

HOCH – Fehlende Migration auf Prod:
portalPasswordMustChange war im Code, aber prod-DB hatte die
Spalte nicht → jeder Kunden-Login warf Prisma-Schema-Error → DoS.
Root Cause: db push statt migrate dev während Entwicklung →
kein Migration-File im Repo. Fix: handgenerierte Migration
20260516173552_portal_password_must_change/migration.sql, lokal
mit migrate resolve --applied registriert, durch shadow-DB-Reset
verifiziert. entrypoint.sh führt migrate deploy bereits aus.

MITTEL – Prisma-Internals-Leak im Login-Error:
error.message wurde 1:1 an den Client gegeben → bei DB-Schema-
Fehlern leakten Tabellen- und Spaltennamen. Whitelist-Filter
safeLoginError() in auth.controller.ts: nur 'Ungültige
Anmeldedaten' und 'E-Mail und Passwort erforderlich' werden
durchgereicht, alles andere wird zu generischem 'Anmeldung
fehlgeschlagen' maskiert. Original landet im Server-Log.

Live-verifiziert:
- POST /api/developer/setup → HTTP 404
- Falsches Customer-PW → 'Ungültige Anmeldedaten' (keine Internals)
- Spalte testweise gedropped → 'Anmeldung fehlgeschlagen' (generisch),
  Original-Message nur im Server-Log
- Shadow-DB-Reset + migrate deploy → Spalte korrekt erzeugt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:39:02 +02:00

474 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 { Router, Response } from 'express';
import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
const router = Router();
// HINWEIS: Der frühere `POST /setup`-Endpoint wurde entfernt (Pentest Runde 3
// 2026-05-16 KRITISCH). Er war ohne Auth erreichbar und konnte
// `developer:access` an die Admin-Rolle hängen → Privilege-Escalation auf
// volle DB-Kontrolle. Wenn die developer:access-Permission manuell gesetzt
// werden muss, gibt es das CLI-Script `prisma/add-developer-permission.ts`.
// Tabellen-Metadaten mit Beziehungen
const tableMetadata: Record<string, {
model: string;
primaryKey: string;
readonlyFields: string[];
requiredFields: string[];
relations: { field: string; targetTable: string; type: 'one' | 'many' }[];
foreignKeys: { field: string; targetTable: string }[];
}> = {
User: {
model: 'user',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt', 'password'],
requiredFields: ['email', 'firstName', 'lastName'],
relations: [
{ field: 'customer', targetTable: 'Customer', type: 'one' },
{ field: 'roles', targetTable: 'UserRole', type: 'many' },
],
foreignKeys: [{ field: 'customerId', targetTable: 'Customer' }],
},
Role: {
model: 'role',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt'],
requiredFields: ['name'],
relations: [
{ field: 'permissions', targetTable: 'RolePermission', type: 'many' },
{ field: 'users', targetTable: 'UserRole', type: 'many' },
],
foreignKeys: [],
},
Permission: {
model: 'permission',
primaryKey: 'id',
readonlyFields: ['id'],
requiredFields: ['resource', 'action'],
relations: [{ field: 'roles', targetTable: 'RolePermission', type: 'many' }],
foreignKeys: [],
},
RolePermission: {
model: 'rolePermission',
primaryKey: 'roleId,permissionId',
readonlyFields: [],
requiredFields: ['roleId', 'permissionId'],
relations: [],
foreignKeys: [
{ field: 'roleId', targetTable: 'Role' },
{ field: 'permissionId', targetTable: 'Permission' },
],
},
UserRole: {
model: 'userRole',
primaryKey: 'userId,roleId',
readonlyFields: [],
requiredFields: ['userId', 'roleId'],
relations: [],
foreignKeys: [
{ field: 'userId', targetTable: 'User' },
{ field: 'roleId', targetTable: 'Role' },
],
},
Customer: {
model: 'customer',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt', 'customerNumber'],
requiredFields: ['firstName', 'lastName'],
relations: [
{ field: 'user', targetTable: 'User', type: 'one' },
{ field: 'addresses', targetTable: 'Address', type: 'many' },
{ field: 'bankCards', targetTable: 'BankCard', type: 'many' },
{ field: 'identityDocuments', targetTable: 'IdentityDocument', type: 'many' },
{ field: 'meters', targetTable: 'Meter', type: 'many' },
{ field: 'contracts', targetTable: 'Contract', type: 'many' },
],
foreignKeys: [],
},
Address: {
model: 'address',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt'],
requiredFields: ['customerId', 'street', 'houseNumber', 'postalCode', 'city'],
relations: [
{ field: 'customer', targetTable: 'Customer', type: 'one' },
{ field: 'contracts', targetTable: 'Contract', type: 'many' },
],
foreignKeys: [{ field: 'customerId', targetTable: 'Customer' }],
},
BankCard: {
model: 'bankCard',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt'],
requiredFields: ['customerId', 'accountHolder', 'iban'],
relations: [
{ field: 'customer', targetTable: 'Customer', type: 'one' },
{ field: 'contracts', targetTable: 'Contract', type: 'many' },
],
foreignKeys: [{ field: 'customerId', targetTable: 'Customer' }],
},
IdentityDocument: {
model: 'identityDocument',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt'],
requiredFields: ['customerId', 'documentNumber'],
relations: [
{ field: 'customer', targetTable: 'Customer', type: 'one' },
{ field: 'contracts', targetTable: 'Contract', type: 'many' },
],
foreignKeys: [{ field: 'customerId', targetTable: 'Customer' }],
},
Meter: {
model: 'meter',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt'],
requiredFields: ['customerId', 'meterNumber', 'type'],
relations: [
{ field: 'customer', targetTable: 'Customer', type: 'one' },
{ field: 'readings', targetTable: 'MeterReading', type: 'many' },
{ field: 'energyDetails', targetTable: 'EnergyContractDetails', type: 'many' },
],
foreignKeys: [{ field: 'customerId', targetTable: 'Customer' }],
},
MeterReading: {
model: 'meterReading',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt'],
requiredFields: ['meterId', 'readingDate', 'value'],
relations: [{ field: 'meter', targetTable: 'Meter', type: 'one' }],
foreignKeys: [{ field: 'meterId', targetTable: 'Meter' }],
},
SalesPlatform: {
model: 'salesPlatform',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt'],
requiredFields: ['name'],
relations: [{ field: 'contracts', targetTable: 'Contract', type: 'many' }],
foreignKeys: [],
},
Contract: {
model: 'contract',
primaryKey: 'id',
readonlyFields: ['id', 'createdAt', 'updatedAt', 'contractNumber'],
requiredFields: ['customerId', 'type'],
relations: [
{ field: 'customer', targetTable: 'Customer', type: 'one' },
{ field: 'address', targetTable: 'Address', type: 'one' },
{ field: 'bankCard', targetTable: 'BankCard', type: 'one' },
{ field: 'identityDocument', targetTable: 'IdentityDocument', type: 'one' },
{ field: 'salesPlatform', targetTable: 'SalesPlatform', type: 'one' },
{ field: 'previousContract', targetTable: 'Contract', type: 'one' },
{ field: 'followUpContract', targetTable: 'Contract', type: 'one' },
{ field: 'energyDetails', targetTable: 'EnergyContractDetails', type: 'one' },
{ field: 'internetDetails', targetTable: 'InternetContractDetails', type: 'one' },
{ field: 'mobileDetails', targetTable: 'MobileContractDetails', type: 'one' },
{ field: 'tvDetails', targetTable: 'TvContractDetails', type: 'one' },
{ field: 'carInsuranceDetails', targetTable: 'CarInsuranceDetails', type: 'one' },
],
foreignKeys: [
{ field: 'customerId', targetTable: 'Customer' },
{ field: 'addressId', targetTable: 'Address' },
{ field: 'bankCardId', targetTable: 'BankCard' },
{ field: 'identityDocumentId', targetTable: 'IdentityDocument' },
{ field: 'salesPlatformId', targetTable: 'SalesPlatform' },
{ field: 'previousContractId', targetTable: 'Contract' },
],
},
EnergyContractDetails: {
model: 'energyContractDetails',
primaryKey: 'id',
readonlyFields: ['id'],
requiredFields: ['contractId'],
relations: [
{ field: 'contract', targetTable: 'Contract', type: 'one' },
{ field: 'meter', targetTable: 'Meter', type: 'one' },
],
foreignKeys: [
{ field: 'contractId', targetTable: 'Contract' },
{ field: 'meterId', targetTable: 'Meter' },
],
},
InternetContractDetails: {
model: 'internetContractDetails',
primaryKey: 'id',
readonlyFields: ['id'],
requiredFields: ['contractId'],
relations: [
{ field: 'contract', targetTable: 'Contract', type: 'one' },
{ field: 'phoneNumbers', targetTable: 'PhoneNumber', type: 'many' },
],
foreignKeys: [{ field: 'contractId', targetTable: 'Contract' }],
},
PhoneNumber: {
model: 'phoneNumber',
primaryKey: 'id',
readonlyFields: ['id'],
requiredFields: ['internetContractDetailsId', 'phoneNumber'],
relations: [{ field: 'internetDetails', targetTable: 'InternetContractDetails', type: 'one' }],
foreignKeys: [{ field: 'internetContractDetailsId', targetTable: 'InternetContractDetails' }],
},
MobileContractDetails: {
model: 'mobileContractDetails',
primaryKey: 'id',
readonlyFields: ['id'],
requiredFields: ['contractId'],
relations: [{ field: 'contract', targetTable: 'Contract', type: 'one' }],
foreignKeys: [{ field: 'contractId', targetTable: 'Contract' }],
},
TvContractDetails: {
model: 'tvContractDetails',
primaryKey: 'id',
readonlyFields: ['id'],
requiredFields: ['contractId'],
relations: [{ field: 'contract', targetTable: 'Contract', type: 'one' }],
foreignKeys: [{ field: 'contractId', targetTable: 'Contract' }],
},
CarInsuranceDetails: {
model: 'carInsuranceDetails',
primaryKey: 'id',
readonlyFields: ['id'],
requiredFields: ['contractId'],
relations: [{ field: 'contract', targetTable: 'Contract', type: 'one' }],
foreignKeys: [{ field: 'contractId', targetTable: 'Contract' }],
},
};
// Schema-Informationen abrufen
router.get(
'/schema',
authenticate,
requirePermission('developer:access'),
async (req: AuthRequest, res: Response) => {
try {
const tables = Object.entries(tableMetadata).map(([name, meta]) => ({
name,
...meta,
}));
res.json({ success: true, data: tables });
} catch (error) {
console.error('Schema error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden des Schemas' });
}
}
);
// Tabellen-Daten abrufen
router.get(
'/table/:tableName',
authenticate,
requirePermission('developer:access'),
async (req: AuthRequest, res: Response) => {
try {
const { tableName } = req.params;
const { page = '1', limit = '50' } = req.query;
const meta = tableMetadata[tableName];
if (!meta) {
res.status(404).json({ success: false, error: 'Tabelle nicht gefunden' });
return;
}
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
const take = parseInt(limit as string);
const model = (prisma as any)[meta.model];
const [data, total] = await Promise.all([
model.findMany({
skip,
take,
orderBy: meta.primaryKey.includes(',') ? undefined : { [meta.primaryKey.split(',')[0]]: 'desc' },
}),
model.count(),
]);
res.json({
success: true,
data,
meta: {
...meta,
tableName,
},
pagination: {
page: parseInt(page as string),
limit: parseInt(limit as string),
total,
totalPages: Math.ceil(total / parseInt(limit as string)),
},
});
} catch (error) {
console.error('Table data error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden der Daten' });
}
}
);
// Einzelne Zeile aktualisieren
router.put(
'/table/:tableName/:id',
authenticate,
requirePermission('developer:access'),
async (req: AuthRequest, res: Response) => {
try {
const { tableName, id } = req.params;
const updates = req.body;
const meta = tableMetadata[tableName];
if (!meta) {
res.status(404).json({ success: false, error: 'Tabelle nicht gefunden' });
return;
}
// Readonly-Felder aus Updates entfernen
const filteredUpdates: Record<string, any> = {};
for (const [key, value] of Object.entries(updates)) {
if (!meta.readonlyFields.includes(key)) {
filteredUpdates[key] = value;
}
}
// Prüfen ob required-Felder nicht auf null/leer gesetzt werden
for (const field of meta.requiredFields) {
if (field in filteredUpdates && (filteredUpdates[field] === null || filteredUpdates[field] === '')) {
res.status(400).json({ success: false, error: `Feld '${field}' ist erforderlich` });
return;
}
}
const model = (prisma as any)[meta.model];
// Composite Primary Key Handling
let where: any;
if (meta.primaryKey.includes(',')) {
const keys = meta.primaryKey.split(',');
const idParts = id.split('-');
where = {};
keys.forEach((key, idx) => {
where[key] = parseInt(idParts[idx]);
});
} else {
where = { [meta.primaryKey]: parseInt(id) };
}
const updated = await model.update({
where,
data: filteredUpdates,
});
res.json({ success: true, data: updated });
} catch (error: any) {
console.error('Update error:', error);
if (error.code === 'P2003') {
res.status(400).json({ success: false, error: 'Fremdschlüssel-Verletzung: Referenzierter Datensatz existiert nicht' });
} else if (error.code === 'P2002') {
res.status(400).json({ success: false, error: 'Unique-Constraint-Verletzung: Wert existiert bereits' });
} else {
res.status(500).json({ success: false, error: 'Fehler beim Aktualisieren' });
}
}
}
);
// Zeile löschen (nur wenn keine abhängigen Daten)
router.delete(
'/table/:tableName/:id',
authenticate,
requirePermission('developer:access'),
async (req: AuthRequest, res: Response) => {
try {
const { tableName, id } = req.params;
const meta = tableMetadata[tableName];
if (!meta) {
res.status(404).json({ success: false, error: 'Tabelle nicht gefunden' });
return;
}
const model = (prisma as any)[meta.model];
// Composite Primary Key Handling
let where: any;
if (meta.primaryKey.includes(',')) {
const keys = meta.primaryKey.split(',');
const idParts = id.split('-');
where = {};
keys.forEach((key, idx) => {
where[key] = parseInt(idParts[idx]);
});
} else {
where = { [meta.primaryKey]: parseInt(id) };
}
// Prüfen ob abhängige Daten existieren (nur "many"-Relations)
const record = await model.findUnique({
where,
include: meta.relations
.filter((r) => r.type === 'many')
.reduce((acc, r) => ({ ...acc, [r.field]: { take: 1 } }), {}),
});
if (!record) {
res.status(404).json({ success: false, error: 'Datensatz nicht gefunden' });
return;
}
// Prüfen auf abhängige Daten
for (const rel of meta.relations.filter((r) => r.type === 'many')) {
if (record[rel.field] && record[rel.field].length > 0) {
res.status(400).json({
success: false,
error: `Kann nicht gelöscht werden: Es existieren abhängige Daten in '${rel.targetTable}'`,
});
return;
}
}
await model.delete({ where });
res.json({ success: true });
} catch (error: any) {
console.error('Delete error:', error);
if (error.code === 'P2003') {
res.status(400).json({ success: false, error: 'Kann nicht gelöscht werden: Fremdschlüssel-Abhängigkeit' });
} else {
res.status(500).json({ success: false, error: 'Fehler beim Löschen' });
}
}
}
);
// Referenzierte Daten für Dropdowns abrufen
router.get(
'/reference/:tableName',
authenticate,
requirePermission('developer:access'),
async (req: AuthRequest, res: Response) => {
try {
const { tableName } = req.params;
const { search = '', limit = '50' } = req.query;
const meta = tableMetadata[tableName];
if (!meta) {
res.status(404).json({ success: false, error: 'Tabelle nicht gefunden' });
return;
}
const model = (prisma as any)[meta.model];
const data = await model.findMany({
take: parseInt(limit as string),
});
res.json({ success: true, data });
} catch (error) {
console.error('Reference error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden der Referenzdaten' });
}
}
);
export default router;