a7d12b8540
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>
474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
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;
|