R188/R189-01 aus dem Pentest umgesetzt - Klasse statt Instanz
Beide Findings der Pentesterin waren berechtigt. Ihre Patches liessen sich nicht anwenden (Basis791711c, seitdem 42 Commits, sync-roles.ts kollidiert), und an zwei Stellen greifen sie zu kurz. R188 - ungueltige IDs im Pfad ----------------------------- Gemeldet: GET /api/users/:id gibt bei nicht-numerischer ID 500 statt 400. Ihr Fix schliesst nebenbei mehr, als sie beansprucht: parseInt('12abc') ergibt 12, also lieferte /api/users/12abc bisher Benutzer 12 aus. Es waren aber 181 ungepruefte Stellen in 19 Controllern, nicht eine. 181 Einzel-Guards waeren genau der Fehler aus R186-01 gewesen - drei Filterlisten, die dasselbe bedeuten sollten und auseinanderliefen. Stattdessen router.param(), an einer Stelle fuer alle 33 Router registriert, ueber einen mounte()-Helfer, der Pruefung und Einhaengen zusammenbindet. Antwort ist 404, nicht 400: Ein Pfadsegment, das keine ID sein kann, benennt keine Ressource. Der bestehende Praezedenzfall in provider.controller.ts (Pentest Mai 2026) hatte es genauso entschieden. Dabei eine aeltere Heuristik abgeloest (Pentest Runde 7). Ihr eigener Kommentar nannte den Grund fuer sie - "app.param() greift nicht auf in Sub-Router gemounteten Routes" - und genau das loest mounte(). Sie war zu eng (/users/abc ging durch und endete als 500) und zu weit (ein Einstellungs-Schluessel 12abc unter :key wurde geblockt, obwohl das keine ID ist), und sie antwortete 400, wo jetzt 404 steht. R189-01 - DSGVO-Rechte ohne Traeger ------------------------------------ Gemeldet: gdpr:* und audit:read/export haengen an DSGVO und Developer, die Admin-Rolle hat sie nicht, und nach einem frischen Seed war DSGVO keinem Konto zugewiesen. Auskunft nach Art. 15 und Loeschung nach Art. 17 konnte niemand ausfuehren. Seed weist admin@admin.com jetzt zusaetzlich die DSGVO-Rolle zu; die Admin-Rolle selbst bleibt ohne diese Rechte, die Trennung aus R186 bleibt also erhalten. Label ehrlich gemacht. Beim Pruefen ihres Patches ein eigener Fund: seed.ts vergab an die DSGVO-Rolle weiterhin audit:* komplett, inklusive audit:admin - die Buendelung, diefc6f39eaufgeloest hat. Ich hatte damals zwei Listen gefunden und die dritte uebersehen. Gerettet hat es nur die Reihenfolge im Containerstart; ein einzelnes `npm run db:seed` brachte sie zurueck. Der Seed hilft nur bei Neuinstallation (update: {}). Deshalb zusaetzlich eine Wache beim Start: Gibt es fuer gdpr:export, gdpr:delete oder audit:read kein aktives Konto, steht das mit Handlungsanweisung im Log - Erkennung der ABWESENHEIT einer Faehigkeit, wie beim Heartbeat. Bewusst nur melden, nicht automatisch vergeben. Getestet ueber HTTP gegen eine Wegwerf-Instanz: alle ID-Varianten quer ueber sechs Controller, Nicht-ID-Parameter unveraendert, frischer Seed, Wache mit und ohne vergebene Rechte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-5
@@ -106,7 +106,7 @@ async function main() {
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Admin',
|
||||
description: 'Voller Zugriff auf alle Funktionen',
|
||||
description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)',
|
||||
permissions: {
|
||||
create: adminPermissions.map((p) => ({ permissionId: p.id })),
|
||||
},
|
||||
@@ -129,16 +129,29 @@ async function main() {
|
||||
});
|
||||
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
|
||||
|
||||
// DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess)
|
||||
// DSGVO - Datenschutz-Verwaltung plus LESENDER Zugriff aufs Audit-Protokoll.
|
||||
//
|
||||
// Bewusst OHNE `audit:admin`: Wer das Protokoll beaufsichtigt, darf seine
|
||||
// eigene Beweisgrundlage nicht ersetzen koennen (Pentest R186). Die
|
||||
// eingreifenden Rechte liegen in der Rolle `Audit-Betrieb`.
|
||||
//
|
||||
// Diese Liste stand hier bis 09/2026 noch auf `audit:*` komplett und war
|
||||
// damit die dritte Stelle, die denselben Rechtesatz beschrieb - neben
|
||||
// `sync-roles.ts` und dem Notfallpfad in `user.service.ts`. Gerettet hat es
|
||||
// nur die Reihenfolge im Container-Start (sync-roles laeuft danach und
|
||||
// raeumt Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte die
|
||||
// Buendelung zurueck.
|
||||
const gdprPermissions = allPermissions.filter(
|
||||
(p) => p.resource === 'audit' || p.resource === 'gdpr'
|
||||
(p) =>
|
||||
p.resource === 'gdpr' ||
|
||||
(p.resource === 'audit' && (p.action === 'read' || p.action === 'export'))
|
||||
);
|
||||
const gdprRole = await prisma.role.upsert({
|
||||
where: { name: 'DSGVO' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'DSGVO',
|
||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
||||
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
||||
permissions: {
|
||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
||||
},
|
||||
@@ -265,8 +278,19 @@ async function main() {
|
||||
password: hashedPassword,
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
// Zusaetzlich die DSGVO-Rolle (Pentest R189-01).
|
||||
//
|
||||
// Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach
|
||||
// Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte
|
||||
// haengen an DSGVO und Developer, und beide waren keinem Konto
|
||||
// zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts
|
||||
// weiter als eine Neuinstallation.
|
||||
//
|
||||
// Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin
|
||||
// nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt
|
||||
// jemand handlungsfaehig ist.
|
||||
roles: {
|
||||
create: [{ roleId: adminRole.id }],
|
||||
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ async function main() {
|
||||
.map((p) => p.id);
|
||||
|
||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)', permIds: adminPermIds },
|
||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
||||
{ name: 'Audit-Betrieb', description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', permIds: auditBetriebPermIds },
|
||||
|
||||
+65
-54
@@ -1,4 +1,4 @@
|
||||
import express from 'express';
|
||||
import express, { Router } from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
@@ -69,9 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler
|
||||
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||
import { registriereIdPruefung } from './middleware/routeIds.js';
|
||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||
import { auditMiddleware } from './middleware/audit.js';
|
||||
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
|
||||
import { authenticate } from './middleware/auth.js';
|
||||
|
||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||
@@ -326,69 +328,76 @@ app.use('/api', (_req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
||||
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
|
||||
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`,
|
||||
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
|
||||
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
|
||||
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
|
||||
//
|
||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
||||
// nicht geblockt.
|
||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
||||
app.use('/api', (req, res, next) => {
|
||||
for (const seg of req.path.split('/')) {
|
||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router
|
||||
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
|
||||
// zwar in beide Richtungen:
|
||||
//
|
||||
// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
|
||||
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
|
||||
// ID-Parameter und laesst nur kanonische Zahlen zu.
|
||||
// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
|
||||
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
|
||||
//
|
||||
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten
|
||||
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
|
||||
|
||||
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
||||
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
||||
app.use('/api', apiBackstopRateLimiter);
|
||||
|
||||
// Router einhängen – IMMER über `mounte`, nie über `app.use` direkt.
|
||||
//
|
||||
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
|
||||
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
|
||||
// stillschweigend – deshalb steht die Pruefung im selben Handgriff wie das
|
||||
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
|
||||
const mounte = (pfad: string, router: Router): void => {
|
||||
app.use(pfad, registriereIdPruefung(router));
|
||||
};
|
||||
|
||||
// Öffentliche Routes (OHNE Authentifizierung)
|
||||
app.use('/api/public/consent', consentPublicRoutes);
|
||||
mounte('/api/public/consent', consentPublicRoutes);
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/customers', customerRoutes);
|
||||
app.use('/api/addresses', addressRoutes);
|
||||
app.use('/api/bank-cards', bankcardRoutes);
|
||||
app.use('/api/documents', documentRoutes);
|
||||
app.use('/api/meters', meterRoutes);
|
||||
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||
app.use('/api/contracts', contractRoutes);
|
||||
app.use('/api/credit-notes', creditNoteRoutes);
|
||||
app.use('/api/company-profile', companyProfileRoutes);
|
||||
app.use('/api/platforms', platformRoutes);
|
||||
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||
app.use('/api/contract-durations', contractDurationRoutes);
|
||||
app.use('/api/providers', providerRoutes);
|
||||
app.use('/api/tariffs', tariffRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/developer', developerRoutes);
|
||||
app.use('/api/contract-categories', contractCategoryRoutes);
|
||||
app.use('/api', contractTaskRoutes);
|
||||
app.use('/api/settings', appSettingRoutes);
|
||||
app.use('/api/email-providers', emailProviderRoutes);
|
||||
app.use('/api', cachedEmailRoutes);
|
||||
app.use('/api/energy-details', invoiceRoutes);
|
||||
app.use('/api', contractHistoryRoutes);
|
||||
app.use('/api/audit-logs', auditLogRoutes);
|
||||
app.use('/api/gdpr', gdprRoutes);
|
||||
app.use('/api/email-logs', emailLogRoutes);
|
||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||
app.use('/api/birthdays', birthdayRoutes);
|
||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
app.use('/api/monitoring', monitoringRoutes);
|
||||
mounte('/api/auth', authRoutes);
|
||||
mounte('/api/customers', customerRoutes);
|
||||
mounte('/api/addresses', addressRoutes);
|
||||
mounte('/api/bank-cards', bankcardRoutes);
|
||||
mounte('/api/documents', documentRoutes);
|
||||
mounte('/api/meters', meterRoutes);
|
||||
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||
mounte('/api/contracts', contractRoutes);
|
||||
mounte('/api/credit-notes', creditNoteRoutes);
|
||||
mounte('/api/company-profile', companyProfileRoutes);
|
||||
mounte('/api/platforms', platformRoutes);
|
||||
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||
mounte('/api/contract-durations', contractDurationRoutes);
|
||||
mounte('/api/providers', providerRoutes);
|
||||
mounte('/api/tariffs', tariffRoutes);
|
||||
mounte('/api/users', userRoutes);
|
||||
mounte('/api/upload', uploadRoutes);
|
||||
mounte('/api/developer', developerRoutes);
|
||||
mounte('/api/contract-categories', contractCategoryRoutes);
|
||||
mounte('/api', contractTaskRoutes);
|
||||
mounte('/api/settings', appSettingRoutes);
|
||||
mounte('/api/email-providers', emailProviderRoutes);
|
||||
mounte('/api', cachedEmailRoutes);
|
||||
mounte('/api/energy-details', invoiceRoutes);
|
||||
mounte('/api', contractHistoryRoutes);
|
||||
mounte('/api/audit-logs', auditLogRoutes);
|
||||
mounte('/api/gdpr', gdprRoutes);
|
||||
mounte('/api/email-logs', emailLogRoutes);
|
||||
mounte('/api/pdf-templates', pdfTemplateRoutes);
|
||||
mounte('/api/birthdays', birthdayRoutes);
|
||||
mounte('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
mounte('/api/monitoring', monitoringRoutes);
|
||||
|
||||
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
||||
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
||||
@@ -491,6 +500,8 @@ const LISTEN_ADDR = process.env.LISTEN_ADDR
|
||||
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||
starteHeartbeatMonitor();
|
||||
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
|
||||
void pruefePflichtrechte();
|
||||
|
||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Request, Response, NextFunction, Router, RequestParamHandler } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Numerische Pfad-Parameter zentral pruefen (Pentest R188).
|
||||
*
|
||||
* Ausgangslage: In den Controllern stand 181-mal `parseInt(req.params.<x>)`
|
||||
* ohne Pruefung. Das hatte zwei Folgen, und die zweite ist die unangenehmere:
|
||||
*
|
||||
* 1. Ein nicht-numerisches Segment ergab `NaN`, Prisma lief auf und der
|
||||
* Handler antwortete mit **500**. `GET /api/users/permissions` etwa
|
||||
* trifft `/:id` und war damit ein Serverfehler statt „gibt es nicht".
|
||||
*
|
||||
* 2. `parseInt` liest so weit, wie es kann: `parseInt('12abc')` ist **12**.
|
||||
* `GET /api/users/12abc` lieferte also Benutzer 12 aus. Eine schlampig
|
||||
* geratene ID wurde stillschweigend zu einer gueltigen gemacht.
|
||||
*
|
||||
* Die naheliegende Antwort waere gewesen, an allen 181 Stellen einen Guard
|
||||
* einzusetzen. Genau daran haben wir uns in dieser Reihe mehrfach die Finger
|
||||
* verbrannt: Was an vielen Stellen gepflegt werden muss, laeuft auseinander
|
||||
* (zuletzt R186-01, drei Filterlisten, die dasselbe bedeuten sollten). Deshalb
|
||||
* eine Stelle statt 181 – und eine neue Route ist automatisch mit abgedeckt.
|
||||
*
|
||||
* Umgesetzt ueber `router.param()`: Express ruft den Callback, bevor der
|
||||
* Handler laeuft, und nur fuer Routen, die den Parameter wirklich benutzen.
|
||||
*
|
||||
* **Antwort ist 404, nicht 400.** Ein Pfad-Segment, das keine ID sein kann,
|
||||
* benennt keine Ressource – das ist „nicht gefunden", nicht „falsch gefragt".
|
||||
* Der Codebestand hat es an der einzigen bereits abgesicherten Stelle
|
||||
* (`provider.controller.ts`, Pentest Mai 2026) genauso entschieden. Nebenbei
|
||||
* verraet eine einheitliche 404 einem Probierenden nicht, ob ein Endpunkt
|
||||
* existiert und eine Zahl erwartet oder ob es den Pfad gar nicht gibt.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parameternamen, die eine Datenbank-ID bezeichnen – abgeglichen mit allen
|
||||
* Routendateien. Bewusst NICHT dabei und deshalb unberuehrt: `consentType`,
|
||||
* `filename`, `hash`, `key`, `localPart`, `name`, `tableName`.
|
||||
*
|
||||
* Die Namensregel „heisst `id` oder endet auf `Id`" gilt im gesamten Bestand;
|
||||
* eine neue Route mit `:fooId` gehoert hier ergaenzt.
|
||||
*/
|
||||
const ID_PARAMETER = [
|
||||
'id',
|
||||
'contractId',
|
||||
'contractMeterId',
|
||||
'customerId',
|
||||
'documentId',
|
||||
'ecdId',
|
||||
'emailId',
|
||||
'entryId',
|
||||
'invoiceId',
|
||||
'meterId',
|
||||
'phoneNumberId',
|
||||
'providerId',
|
||||
'readingId',
|
||||
'referralId',
|
||||
'representativeId',
|
||||
'simCardId',
|
||||
'subtaskId',
|
||||
'taskId',
|
||||
] as const;
|
||||
|
||||
/** Obergrenze von MySQL INT – darueber gibt es keine Zeile, nur einen Fehler. */
|
||||
const MAX_ID = 2147483647;
|
||||
|
||||
/**
|
||||
* Gueltig ist ausschliesslich eine kanonische positive Ganzzahl.
|
||||
*
|
||||
* Fuehrende Nullen werden abgelehnt: `007` und `7` wuerden dieselbe Zeile
|
||||
* bezeichnen, und zwei Schreibweisen fuer dieselbe Ressource sind eine
|
||||
* unnoetige Einladung – etwa fuer Zaehler, Zwischenspeicher oder Sperren, die
|
||||
* auf dem Pfad als Schluessel arbeiten.
|
||||
*/
|
||||
export function istGueltigeId(wert: string): boolean {
|
||||
if (!/^[1-9][0-9]*$/.test(wert)) return false;
|
||||
const n = Number(wert);
|
||||
return Number.isSafeInteger(n) && n <= MAX_ID;
|
||||
}
|
||||
|
||||
const pruefeIdParameter: RequestParamHandler = (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
wert: unknown,
|
||||
) => {
|
||||
if (typeof wert === 'string' && istGueltigeId(wert)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// Bewusst ohne Angabe, WELCHER Parameter beanstandet wurde: Die Meldung
|
||||
// soll nicht zur Landkarte werden, welche Endpunkte welche IDs erwarten.
|
||||
res.status(404).json({ success: false, error: 'Nicht gefunden' } as ApiResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Haengt die Pruefung an einen Router. Namen, die der Router gar nicht
|
||||
* verwendet, kosten nichts – Express ruft den Callback nur fuer Parameter,
|
||||
* die in einer getroffenen Route vorkommen.
|
||||
*/
|
||||
export function registriereIdPruefung<T extends Router>(router: T): T {
|
||||
for (const name of ID_PARAMETER) {
|
||||
router.param(name, pruefeIdParameter);
|
||||
}
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import prisma from '../lib/prisma.js';
|
||||
|
||||
/**
|
||||
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
|
||||
* ausueben kann (Pentest R189-01).
|
||||
*
|
||||
* Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den
|
||||
* Rollen DSGVO und Developer – nicht an der Admin-Rolle. Nach einem frischen
|
||||
* Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft
|
||||
* nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren,
|
||||
* obwohl mehrere „Admin"-Konten existierten. Ein Ausfall mit Fristwirkung,
|
||||
* ausgeloest durch nichts weiter als eine Neuinstallation.
|
||||
*
|
||||
* Der Seed weist das Recht jetzt zu – aber nur bei NEUINSTALLATION. Auf einer
|
||||
* laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf
|
||||
* ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit,
|
||||
* so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt.
|
||||
*
|
||||
* Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen,
|
||||
* ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler.
|
||||
*/
|
||||
|
||||
/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */
|
||||
const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> = [
|
||||
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
|
||||
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
|
||||
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
|
||||
];
|
||||
|
||||
export async function pruefePflichtrechte(): Promise<void> {
|
||||
try {
|
||||
const fehlend: string[] = [];
|
||||
|
||||
for (const recht of PFLICHTRECHTE) {
|
||||
const traeger = await prisma.user.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
roles: {
|
||||
some: {
|
||||
role: {
|
||||
permissions: {
|
||||
some: {
|
||||
permission: { resource: recht.resource, action: recht.action },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (traeger === 0) {
|
||||
fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fehlend.length === 0) return;
|
||||
|
||||
console.warn(
|
||||
'\n' +
|
||||
'========================================================================\n' +
|
||||
' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' +
|
||||
fehlend.map((f) => ` – ${f}\n`).join('') +
|
||||
'\n' +
|
||||
' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' +
|
||||
' Rechtevergabe – und sie fällt erst auf, wenn eine Frist läuft.\n' +
|
||||
'\n' +
|
||||
' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' +
|
||||
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
|
||||
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' +
|
||||
' aufräumen, Aufbewahrung ändern – zusätzlich „Audit-Betrieb".\n' +
|
||||
'========================================================================\n',
|
||||
);
|
||||
} catch (err) {
|
||||
// Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie
|
||||
// auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden".
|
||||
console.warn(
|
||||
'[Pflichtrechte] Prüfung nicht möglich – der Zustand der Rechtevergabe ist ' +
|
||||
'damit UNBEKANNT, nicht in Ordnung:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,62 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **🔢 R188: Ungültige IDs im Pfad – zentral statt 181-mal** (2026-09-03)
|
||||
- Meldung der Pentesterin: `GET /api/users/:id` gibt bei nicht-numerischer
|
||||
ID **500** statt 400 (`/api/users/permissions` trifft `/:id`). Ihr Patch
|
||||
setzt einen Guard in `getUser`.
|
||||
- **Berechtigt – und größer als gemeldet.** Ihr Fix schließt nebenbei etwas,
|
||||
das sie nicht beansprucht: `parseInt('12abc')` ergibt **12**, also lieferte
|
||||
`GET /api/users/12abc` bisher Benutzer 12 aus. Und es waren **181**
|
||||
ungeprüfte Stellen in 19 Controllern, nicht eine.
|
||||
- 181 Einzel-Guards wären genau der Fehler aus R186-01 gewesen (drei
|
||||
Filterlisten, die auseinanderliefen). Stattdessen `router.param()`, an
|
||||
**einer** Stelle für alle 33 Router registriert – über einen `mounte()`-
|
||||
Helfer, der Prüfung und Einhängen zusammenbindet. Wer künftig `app.use`
|
||||
schreibt statt `mounte`, umgeht sie nicht versehentlich, sondern sichtbar.
|
||||
- **Antwort ist 404, nicht 400:** Ein Pfadsegment, das keine ID sein kann,
|
||||
benennt keine Ressource. Der bestehende Präzedenzfall
|
||||
(`provider.controller.ts`, Pentest Mai 2026) hatte es genauso entschieden.
|
||||
- **Eine ältere Heuristik abgelöst** (Pentest Runde 7): Sie blockte
|
||||
`^\d+[a-zA-Z]+$` im Pfad – ihr eigener Kommentar nannte den Grund,
|
||||
„`app.param()` greift nicht auf in Sub-Router gemounteten Routes", und
|
||||
genau das löst `mounte()`. Sie war **zu eng** (`/users/abc` ging durch →
|
||||
500) und **zu weit** (ein Einstellungs-Schlüssel `12abc` unter `:key` wurde
|
||||
geblockt, obwohl das keine ID ist), und sie antwortete 400, wo jetzt 404
|
||||
steht – zwei Antworten für dieselbe Eingabeklasse.
|
||||
- Getestet über HTTP: `abc`, `permissions`, `12abc`, `6abc`, `0`, `-1`,
|
||||
`007`, `1e3`, Überlauf, `1;DROP` → alle **404**; `1` → 200. Quer über
|
||||
sechs Controller gleich. Nicht-ID-Parameter (`roles/list`,
|
||||
`permissions/list`, `settings/:key`) unverändert.
|
||||
- Dateien: `backend/src/middleware/routeIds.ts` (neu), `backend/src/index.ts`
|
||||
|
||||
- [x] **⚖️ R189-01: DSGVO-Rechte hingen an keinem Konto** (2026-09-03)
|
||||
- Meldung: `gdpr:*` und `audit:read/export` hängen an den Rollen DSGVO und
|
||||
Developer – die Admin-Rolle hat sie **nicht**, und nach einem frischen Seed
|
||||
war DSGVO **keinem Konto** zugewiesen. Auskunft nach Art. 15 und Löschung
|
||||
nach Art. 17 konnte damit niemand ausführen. Ausfall mit Fristwirkung.
|
||||
- Seed weist `admin@admin.com` jetzt zusätzlich die DSGVO-Rolle zu. Die
|
||||
Admin-**Rolle** bekommt diese Rechte weiterhin nicht – die Trennung aus
|
||||
R186 bleibt, nur das eine Bootstrap-Konto trägt beides.
|
||||
- Label ehrlich gemacht: „Voller Zugriff auf alle **Fachfunktionen** (ohne
|
||||
Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)".
|
||||
- **Eigener Fund beim Prüfen ihres Patches:** `seed.ts` vergab an die
|
||||
DSGVO-Rolle weiterhin `audit:*` **komplett**, inklusive `audit:admin` –
|
||||
also die Bündelung, die in `fc6f39e` aufgelöst wurde. Ich hatte damals zwei
|
||||
Listen gefunden (`sync-roles.ts`, `user.service.ts`) und die dritte
|
||||
übersehen. Gerettet hat es nur die Reihenfolge im Containerstart; ein
|
||||
einzelnes `npm run db:seed` brachte sie zurück. Korrigiert.
|
||||
- **Der Seed hilft aber nur bei Neuinstallation** (`update: {}`). Deshalb
|
||||
zusätzlich eine Wache beim Start: Gibt es für `gdpr:export`, `gdpr:delete`
|
||||
oder `audit:read` **kein aktives Konto**, steht das mit Handlungsanweisung
|
||||
im Log. Erkennt die *Abwesenheit* einer Fähigkeit – dasselbe Muster wie der
|
||||
Heartbeat. Bewusst nur melden, nicht automatisch vergeben.
|
||||
- Getestet: frischer Seed → Admin hat `audit:read, audit:export, gdpr:*` und
|
||||
**kein** `audit:admin`. Rolle entzogen → Warnung erscheint mit allen drei
|
||||
Rechten. Rolle zurück → still.
|
||||
- Dateien: `backend/prisma/seed.ts`, `backend/prisma/sync-roles.ts`,
|
||||
`backend/src/services/pflichtrechte.service.ts` (neu), `backend/src/index.ts`
|
||||
|
||||
- [x] **🕳️ Entfernter Protokoll-ANFANG wurde nicht erkannt** (2026-08-26)
|
||||
- Gefunden bei der Vorbereitung des Prod-Siegels: Die Verkettung wird
|
||||
zeilenweise gegen die Vorgängerin geprüft – die **erste** Zeile hat keine,
|
||||
|
||||
Reference in New Issue
Block a user