Drei Nachrangpunkte aus dem Pentest, vor dem Schneiden des Rechtekatalogs.
1. R192-01 projektweit. Das Muster `error instanceof Error ? error.message`
stand 124-mal in 26 Controllern und konnte ueberall Serverpfade,
Spaltennamen und Bibliotheksinterna ausliefern. Zentral geloest statt
124-mal einzeln - das waere die Falle aus R186-01 und R188 gewesen:
utils/fehlerAntwort.ts mit antworteAufFehler(), jetzt 123 Aufrufe in 26
Dateien und eine Regel.
Die Unterscheidung laeuft ueber die Fehlerklasse. Neue Basisklasse
FachlicherFehler fuer alles, dessen Wortlaut fuer den Aufrufer bestimmt
ist; ApiError, RechteEskalationError, RollenSperrError,
UngueltigeEingabeError, FilterFehler und ReferralError stammen davon ab.
Ein blankes Error gilt weiter als absichtlich. Alles andere - TypeError,
Prisma, JWT-Bibliothek - wird 500 mit allgemeiner Auskunft, Einzelheiten
ins Protokoll.
Mit gefunden: Sechs Stellen in cachedEmail.controller interpolierten die
interne Meldung in den Antworttext; das haette kein Filter erwischt, der
nur das Feld ersetzt. Und POST /auth/refresh gab den Wortlaut der
JWT-Bibliothek zurueck ("jwt malformed", "invalid signature") - der sagt
einem Angreifer, woran sein Token gescheitert ist.
2. Admin-Heuristik. Bisher galt "wer users:delete hat, ist Admin" - ein
Zufallsmerkmal. Bewusst NICHT auf den Rollennamen umgestellt, wie
vorgeschlagen: Eine selbst gebaute Rolle mit users:update verwaltet
tatsaechlich, ein Namenskriterium wuerde sie uebersehen, und dann liesse
sich der letzte Admin loeschen, obwohl die Faehigkeit erhalten bliebe.
Geschuetzt wird jetzt die Faehigkeit selbst: users:update und
roles:manage. Wer der letzte Traeger ist, kann sie nicht verlieren - durch
Rollenwechsel, Deaktivierung oder Loeschung. Die Meldung nennt die
Faehigkeit beim Namen. Nebenbei der letzte Cost-10-Rest im
Kennwort-Zuruecksetzen.
3. Portal-Kunden. Korrektur meiner eigenen Einordnung: Das war kein
Migrationsrueckstand, sondern eine gewollte Trennung. Kunden bekommen
niemals operative Rechte; die Portalansicht ist dafuer nicht gebaut und
prueft es nicht. Die zwei Kopien des festen Arrays sind jetzt eine
Konstante PORTAL_RECHTE mit einem Kommentar, der die Absicht benennt.
Dazu ein harter Riegel im Gate: requirePermission schneidet die Rechte
eines Portal-Zugangs auf PORTAL_RECHTE zu, unabhaengig davon, was sein
Token behauptet. Heute wirkungslos, morgen die Sicherung - bisher haette
eine unbedachte Zeile in der Token-Erzeugung gereicht. Und eine Startwache,
die jedes Nicht-Lese-Recht in dieser Liste meldet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
import { Response } from 'express';
|
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
|
import { AuthRequest } from '../types/index.js';
|
|
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
|
import { logChange } from '../services/audit.service.js';
|
|
import { canAccessContract } from '../utils/accessControl.js';
|
|
|
|
export async function getTemplates(req: AuthRequest, res: Response) {
|
|
try {
|
|
const templates = await pdfTemplateService.getAllTemplates();
|
|
res.json({ success: true, data: templates });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
|
}
|
|
}
|
|
|
|
export async function getTemplate(req: AuthRequest, res: Response) {
|
|
try {
|
|
const template = await pdfTemplateService.getTemplateById(parseInt(req.params.id));
|
|
if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' });
|
|
res.json({ success: true, data: template });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
|
}
|
|
}
|
|
|
|
export async function createTemplate(req: AuthRequest, res: Response) {
|
|
try {
|
|
if (!req.file) return res.status(400).json({ success: false, error: 'PDF-Datei erforderlich' });
|
|
|
|
const { name, description, providerName, phoneFieldPrefix, maxPhoneFields } = req.body;
|
|
const templatePath = `/uploads/pdf-templates/${req.file.filename}`;
|
|
|
|
// PDF-Felder auslesen
|
|
let pdfFields: { name: string; type: string; page: number; y: number }[] = [];
|
|
try {
|
|
const extracted = await pdfTemplateService.extractPdfFields(templatePath);
|
|
pdfFields = extracted.fields;
|
|
} catch {
|
|
// PDF hat keine Formularfelder - OK, kann trotzdem gespeichert werden
|
|
}
|
|
|
|
const template = await pdfTemplateService.createTemplate({
|
|
name,
|
|
description,
|
|
providerName,
|
|
templatePath,
|
|
originalName: req.file.originalname,
|
|
phoneFieldPrefix,
|
|
maxPhoneFields: maxPhoneFields ? parseInt(maxPhoneFields) : undefined,
|
|
});
|
|
|
|
await logChange({
|
|
req, action: 'CREATE', resourceType: 'PdfTemplate',
|
|
resourceId: template.id.toString(),
|
|
label: `Auftragsvorlage "${name}" angelegt`,
|
|
});
|
|
|
|
res.status(201).json({ success: true, data: { ...template, pdfFields } });
|
|
} catch (error) {
|
|
antworteAufFehler(res, error, 'Fehler beim Erstellen');
|
|
}
|
|
}
|
|
|
|
export async function updateTemplate(req: AuthRequest, res: Response) {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
const { name, description, providerName, fieldMapping, phoneFieldPrefix, maxPhoneFields, isActive } = req.body;
|
|
|
|
const template = await pdfTemplateService.updateTemplate(id, {
|
|
name,
|
|
description,
|
|
providerName,
|
|
fieldMapping: fieldMapping ? JSON.stringify(fieldMapping) : undefined,
|
|
phoneFieldPrefix,
|
|
maxPhoneFields: maxPhoneFields !== undefined ? parseInt(maxPhoneFields) : undefined,
|
|
isActive,
|
|
});
|
|
|
|
await logChange({
|
|
req, action: 'UPDATE', resourceType: 'PdfTemplate',
|
|
resourceId: id.toString(),
|
|
label: `Auftragsvorlage "${template.name}" aktualisiert`,
|
|
});
|
|
|
|
res.json({ success: true, data: template });
|
|
} catch (error) {
|
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
|
}
|
|
}
|
|
|
|
export async function deleteTemplate(req: AuthRequest, res: Response) {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
const template = await pdfTemplateService.getTemplateById(id);
|
|
await pdfTemplateService.deleteTemplate(id);
|
|
await logChange({
|
|
req, action: 'DELETE', resourceType: 'PdfTemplate',
|
|
resourceId: id.toString(),
|
|
label: `Auftragsvorlage "${template?.name}" gelöscht`,
|
|
});
|
|
res.json({ success: true, message: 'Vorlage gelöscht' });
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, error: 'Fehler beim Löschen' });
|
|
}
|
|
}
|
|
|
|
export async function extractFields(req: AuthRequest, res: Response) {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
const template = await pdfTemplateService.getTemplateById(id);
|
|
if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' });
|
|
|
|
const result = await pdfTemplateService.extractPdfFields(template.templatePath);
|
|
res.json({ success: true, data: result.fields, totalPages: result.totalPages });
|
|
} catch (error) {
|
|
antworteAufFehler(res, error, 'Fehler beim Auslesen der PDF-Felder');
|
|
}
|
|
}
|
|
|
|
export async function getAnnotatedPreview(req: AuthRequest, res: Response) {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
const template = await pdfTemplateService.getTemplateById(id);
|
|
if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' });
|
|
|
|
const pdfBuffer = await pdfTemplateService.generateAnnotatedPreview(template.templatePath);
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"');
|
|
res.send(pdfBuffer);
|
|
} catch (error) {
|
|
antworteAufFehler(res, error, 'Fehler', 400);
|
|
}
|
|
}
|
|
|
|
export async function getCrmFields(req: AuthRequest, res: Response) {
|
|
const maxPhoneFields = req.query.maxPhoneFields ? parseInt(req.query.maxPhoneFields as string) : 8;
|
|
res.json({ success: true, data: pdfTemplateService.getCrmFieldsForTemplate(maxPhoneFields) });
|
|
}
|
|
|
|
export async function getRequiredInputs(req: AuthRequest, res: Response) {
|
|
try {
|
|
const templateId = parseInt(req.params.id);
|
|
const contractId = parseInt(req.params.contractId);
|
|
if (!(await canAccessContract(req, res, contractId))) return;
|
|
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
|
res.json({ success: true, data: inputs });
|
|
} catch (error) {
|
|
antworteAufFehler(res, error, 'Fehler', 400);
|
|
}
|
|
}
|
|
|
|
export async function generatePdf(req: AuthRequest, res: Response) {
|
|
try {
|
|
const templateId = parseInt(req.params.id);
|
|
const contractId = parseInt(req.params.contractId);
|
|
if (!(await canAccessContract(req, res, contractId))) return;
|
|
|
|
// Extras aus Body (POST) oder Query-Parametern (GET)
|
|
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
|
|
const manualValues: Record<string, string> = req.body?.manualValues || {};
|
|
|
|
// Manual-Werte aus Query-Parametern extrahieren (manual_manual:1=Wert)
|
|
if (req.query) {
|
|
for (const [key, value] of Object.entries(req.query)) {
|
|
if (key.startsWith('manual_') && typeof value === 'string') {
|
|
manualValues[key.replace('manual_', '')] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const pdfBuffer = await pdfTemplateService.generateFilledPdf(templateId, contractId, {
|
|
stressfreiEmailId: stressfreiEmailId ? parseInt(stressfreiEmailId as string) : undefined,
|
|
manualValues: Object.keys(manualValues).length > 0 ? manualValues : undefined,
|
|
});
|
|
|
|
const template = await pdfTemplateService.getTemplateById(templateId);
|
|
const filename = `${template?.name || 'Auftrag'}_${new Date().toISOString().split('T')[0]}.pdf`;
|
|
|
|
await logChange({
|
|
req, action: 'CREATE', resourceType: 'GeneratedPdf',
|
|
label: `PDF "${template?.name}" generiert für Vertrag #${contractId}`,
|
|
});
|
|
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
|
|
res.send(pdfBuffer);
|
|
} catch (error) {
|
|
console.error('PDF generate error:', error);
|
|
antworteAufFehler(res, error, 'Fehler beim Generieren');
|
|
}
|
|
}
|