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>
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
import { Response } from 'express';
|
||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||
import { logChange } from '../services/audit.service.js';
|
||
import { ApiError } from '../utils/apiError.js';
|
||
import * as creditNoteService from '../services/creditNote.service.js';
|
||
import { effectiveNumber } from '../services/creditNote.service.js';
|
||
import * as numberRangeService from '../services/creditNoteNumberRange.service.js';
|
||
import * as deliveryRangeService from '../services/deliveryNoteNumberRange.service.js';
|
||
import * as authorizationService from '../services/authorization.service.js';
|
||
import { generateCreditNotePdf } from '../services/creditNotePdf.service.js';
|
||
|
||
// Gutschriften sind ein reiner Mitarbeiter-/Admin-Bereich (interne
|
||
// Subventions-Belege). Portal-Kunden dürfen sie weder sehen noch ändern –
|
||
// Defense-in-Depth: nicht nur die UI blendet aus, der Endpoint blockt
|
||
// Portal-Token explizit.
|
||
function blockPortal(req: AuthRequest, res: Response): boolean {
|
||
if (req.user?.isCustomerPortal) {
|
||
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function idParam(req: AuthRequest, res: Response, name: string): number | null {
|
||
const raw = req.params[name];
|
||
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
|
||
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||
return null;
|
||
}
|
||
const parsed = Number.parseInt(raw, 10);
|
||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||
return null;
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function handleError(res: Response, error: unknown, fallback: string) {
|
||
// Eigene Huelle beibehalten, damit die Aufrufer unveraendert bleiben - die
|
||
// Entscheidung, was nach draussen geht, faellt jetzt zentral.
|
||
antworteAufFehler(res, error, fallback, 500);
|
||
}
|
||
|
||
// ---- Gesamtübersicht (Hauptmenü) ----
|
||
// Anders als die übrigen Credit-Note-Endpunkte NICHT staff-only: Portal-Kunden
|
||
// dürfen ihre eigenen (+ vertretene) Belege sehen. Scoping über customerIds
|
||
// aus dem JWT – eine im Body/Query mitgeschickte customerId hat keinen Effekt.
|
||
export async function listAll(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
// Portal-User: nur eigene + vertretene Kunden MIT Vollmacht.
|
||
// Fail-closed (Pentest R144): Ein Portal-Token wird IMMER gescoped. Fehlt
|
||
// wider Erwarten die customerId (null/0), ergibt das eine LEERE Menge –
|
||
// nicht den Staff-Zweig (der alle Belege zeigen würde).
|
||
let customerIds: number[] | undefined;
|
||
if (req.user?.isCustomerPortal) {
|
||
customerIds = req.user.customerId ? [req.user.customerId] : [];
|
||
if (req.user.customerId) {
|
||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||
for (const repCustId of representedIds) {
|
||
if (await authorizationService.hasAuthorization(repCustId, req.user.customerId)) {
|
||
customerIds.push(repCustId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const page = Math.max(parseInt((req.query.page as string) || '1') || 1, 1);
|
||
const limit = Math.min(Math.max(parseInt((req.query.limit as string) || '50') || 50, 1), 200);
|
||
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
|
||
|
||
const result = await creditNoteService.getAllCreditNotes({ customerIds, page, limit, search });
|
||
|
||
// Portal-Kunden dürfen keine Überweisungsbelege laden → receiptPath aus der
|
||
// Response entfernen (Beleg-Download bleibt ohnehin staff-only).
|
||
const isPortal = !!req.user?.isCustomerPortal;
|
||
const items = isPortal
|
||
? result.items.map((cn) => ({ ...cn, receiptPath: null }))
|
||
: result.items;
|
||
|
||
res.json({ success: true, data: { items, pagination: result.pagination } } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Laden der Belegübersicht');
|
||
}
|
||
}
|
||
|
||
// ---- Gutschriften pro Vertrag ----
|
||
|
||
export async function listByContract(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const contractId = idParam(req, res, 'contractId');
|
||
if (contractId === null) return;
|
||
const data = await creditNoteService.getCreditNotesByContract(contractId);
|
||
res.json({ success: true, data } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Laden der Gutschriften');
|
||
}
|
||
}
|
||
|
||
// Vorbelegung fürs Formular: Default-Kundentyp (Firma/Privat aus Kunde) +
|
||
// Vorschau der nächsten Gutschrift-Nummer.
|
||
export async function formDefaults(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const contractId = idParam(req, res, 'contractId');
|
||
if (contractId === null) return;
|
||
const [defaults, nextNumber] = await Promise.all([
|
||
creditNoteService.getCreditNoteDefaults(contractId),
|
||
numberRangeService.previewNextNumber(),
|
||
]);
|
||
res.json({ success: true, data: { ...defaults, nextNumber } } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Laden der Vorbelegung');
|
||
}
|
||
}
|
||
|
||
export async function create(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const contractId = idParam(req, res, 'contractId');
|
||
if (contractId === null) return;
|
||
const created = await creditNoteService.createCreditNote(contractId, req.body ?? {}, req.user?.email);
|
||
await logChange({
|
||
req,
|
||
action: 'CREATE',
|
||
resourceType: 'CreditNote',
|
||
resourceId: created.id.toString(),
|
||
label: `Beleg ${effectiveNumber(created) ?? `#${created.id}`} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`,
|
||
});
|
||
res.status(201).json({ success: true, data: created } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Anlegen der Gutschrift');
|
||
}
|
||
}
|
||
|
||
export async function getOne(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const id = idParam(req, res, 'id');
|
||
if (id === null) return;
|
||
const data = await creditNoteService.getCreditNoteById(id);
|
||
if (!data) {
|
||
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' } as ApiResponse);
|
||
return;
|
||
}
|
||
res.json({ success: true, data } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Laden der Gutschrift');
|
||
}
|
||
}
|
||
|
||
export async function update(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const id = idParam(req, res, 'id');
|
||
if (id === null) return;
|
||
const updated = await creditNoteService.updateCreditNote(id, req.body ?? {});
|
||
await logChange({
|
||
req,
|
||
action: 'UPDATE',
|
||
resourceType: 'CreditNote',
|
||
resourceId: updated.id.toString(),
|
||
label: `Beleg ${effectiveNumber(updated) ?? `#${updated.id}`} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`,
|
||
});
|
||
res.json({ success: true, data: updated } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Ändern der Gutschrift');
|
||
}
|
||
}
|
||
|
||
export async function remove(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const id = idParam(req, res, 'id');
|
||
if (id === null) return;
|
||
const deleted = await creditNoteService.deleteCreditNote(id);
|
||
await logChange({
|
||
req,
|
||
action: 'DELETE',
|
||
resourceType: 'CreditNote',
|
||
resourceId: id.toString(),
|
||
label: `Beleg ${effectiveNumber(deleted) ?? `#${deleted.id}`} gelöscht`,
|
||
});
|
||
res.json({ success: true } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Löschen der Gutschrift');
|
||
}
|
||
}
|
||
|
||
// PDF (Gutschrift-Dokument) erzeugen. ZUGFeRD-Embedding folgt (Teil 2).
|
||
export async function generatePdf(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const id = idParam(req, res, 'id');
|
||
if (id === null) return;
|
||
const { pdfPath } = await generateCreditNotePdf(id);
|
||
await logChange({
|
||
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
|
||
label: 'Gutschrift-PDF erzeugt',
|
||
});
|
||
res.json({ success: true, data: { pdfPath } } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Erzeugen des PDF');
|
||
}
|
||
}
|
||
|
||
// ---- Nummernkreis (Einstellungen) ----
|
||
|
||
export async function getNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const [range, nextNumber] = await Promise.all([
|
||
numberRangeService.getOrCreateRange(),
|
||
numberRangeService.previewNextNumber(),
|
||
]);
|
||
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Laden des Nummernkreises');
|
||
}
|
||
}
|
||
|
||
export async function updateNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const range = await numberRangeService.updateRange(req.body ?? {});
|
||
const preview = await numberRangeService.previewNextNumber();
|
||
await logChange({
|
||
req,
|
||
action: 'UPDATE',
|
||
resourceType: 'CreditNoteNumberRange',
|
||
resourceId: range.id.toString(),
|
||
label: `Gutschrift-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`,
|
||
});
|
||
res.json({ success: true, data: { ...range, preview } } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Speichern des Nummernkreises');
|
||
}
|
||
}
|
||
|
||
// ---- Lieferschein-Nummernkreis (betragslose Sachwerte) ----
|
||
|
||
export async function getDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const [range, nextNumber] = await Promise.all([
|
||
deliveryRangeService.getOrCreateRange(),
|
||
deliveryRangeService.previewNextNumber(),
|
||
]);
|
||
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Laden des Lieferschein-Nummernkreises');
|
||
}
|
||
}
|
||
|
||
export async function updateDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
if (blockPortal(req, res)) return;
|
||
const range = await deliveryRangeService.updateRange(req.body ?? {});
|
||
const preview = await deliveryRangeService.previewNextNumber();
|
||
await logChange({
|
||
req,
|
||
action: 'UPDATE',
|
||
resourceType: 'DeliveryNoteNumberRange',
|
||
resourceId: range.id.toString(),
|
||
label: `Lieferschein-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`,
|
||
});
|
||
res.json({ success: true, data: { ...range, preview } } as ApiResponse);
|
||
} catch (error) {
|
||
handleError(res, error, 'Fehler beim Speichern des Lieferschein-Nummernkreises');
|
||
}
|
||
}
|