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>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `Customer` ADD COLUMN `portalPasswordMustChange` BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -27,6 +27,26 @@ function clearRefreshCookie(res: Response): void {
|
|||||||
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelist von Fehlermeldungen, die wir an Login-Clients durchreichen dürfen.
|
||||||
|
// ALLES andere (Prisma-Internals, DB-Connection-Errors, Schema-Fehler, ...)
|
||||||
|
// wird als generisches "Anmeldung fehlgeschlagen" maskiert – die Original-
|
||||||
|
// Message bleibt im Server-Log, leakt aber nicht im HTTP-Response. Pentest
|
||||||
|
// Runde 3 (2026-05-16): `prisma.customer.findUnique() invocation: The column
|
||||||
|
// X does not exist` war im Body sichtbar → Tabellen-/Spaltennamen geleakt.
|
||||||
|
const SAFE_LOGIN_ERRORS = new Set([
|
||||||
|
'Ungültige Anmeldedaten',
|
||||||
|
'E-Mail und Passwort erforderlich',
|
||||||
|
]);
|
||||||
|
function safeLoginError(err: unknown): string {
|
||||||
|
if (err instanceof Error && SAFE_LOGIN_ERRORS.has(err.message)) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
console.error('[Login] Unerwarteter Fehler (maskiert):', err.message);
|
||||||
|
}
|
||||||
|
return 'Anmeldung fehlgeschlagen';
|
||||||
|
}
|
||||||
|
|
||||||
// Mitarbeiter-Login
|
// Mitarbeiter-Login
|
||||||
export async function login(req: Request, res: Response): Promise<void> {
|
export async function login(req: Request, res: Response): Promise<void> {
|
||||||
const { email, password } = req.body || {};
|
const { email, password } = req.body || {};
|
||||||
@@ -68,7 +88,7 @@ export async function login(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
error: safeLoginError(error),
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +132,7 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
error: safeLoginError(error),
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,15 @@
|
|||||||
import { Router, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Setup-Endpunkt: Erstellt die developer:access Permission und fügt sie der Admin-Rolle hinzu
|
// HINWEIS: Der frühere `POST /setup`-Endpoint wurde entfernt (Pentest Runde 3
|
||||||
// Dieser Endpunkt erfordert keine Authentifizierung, da er nur einmalig zum Setup verwendet wird
|
// 2026-05-16 – KRITISCH). Er war ohne Auth erreichbar und konnte
|
||||||
router.post('/setup', async (req, res: Response) => {
|
// `developer:access` an die Admin-Rolle hängen → Privilege-Escalation auf
|
||||||
try {
|
// volle DB-Kontrolle. Wenn die developer:access-Permission manuell gesetzt
|
||||||
// Create or get the developer:access permission
|
// werden muss, gibt es das CLI-Script `prisma/add-developer-permission.ts`.
|
||||||
const developerPerm = await prisma.permission.upsert({
|
|
||||||
where: { resource_action: { resource: 'developer', action: 'access' } },
|
|
||||||
update: {},
|
|
||||||
create: { resource: 'developer', action: 'access' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the Admin role
|
|
||||||
const adminRole = await prisma.role.findUnique({
|
|
||||||
where: { name: 'Admin' },
|
|
||||||
include: { permissions: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!adminRole) {
|
|
||||||
res.status(404).json({ success: false, error: 'Admin-Rolle nicht gefunden' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Admin already has this permission
|
|
||||||
const hasPermission = adminRole.permissions.some(
|
|
||||||
(rp) => rp.permissionId === developerPerm.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasPermission) {
|
|
||||||
await prisma.rolePermission.create({
|
|
||||||
data: {
|
|
||||||
roleId: adminRole.id,
|
|
||||||
permissionId: developerPerm.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
res.json({ success: true, message: 'developer:access Permission wurde zur Admin-Rolle hinzugefügt. Bitte neu einloggen!' });
|
|
||||||
} else {
|
|
||||||
res.json({ success: true, message: 'Admin-Rolle hat bereits die developer:access Permission' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Setup error:', error);
|
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Setup' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tabellen-Metadaten mit Beziehungen
|
// Tabellen-Metadaten mit Beziehungen
|
||||||
const tableMetadata: Record<string, {
|
const tableMetadata: Record<string, {
|
||||||
|
|||||||
@@ -97,6 +97,37 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🚨 Pentest Runde 3 – drei Findings gefixt**
|
||||||
|
- **KRITISCH – `POST /api/developer/setup` ohne Auth (Privilege
|
||||||
|
Escalation)**: Endpoint war komplett ohne Authentifizierung
|
||||||
|
erreichbar und konnte der Admin-Rolle die `developer:access`-
|
||||||
|
Permission verleihen → kompletter DB-Zugriff über `/developer/*`.
|
||||||
|
**Fix**: Endpoint ersatzlos gelöscht. Manuelles Setzen geht
|
||||||
|
weiterhin über `prisma/add-developer-permission.ts` (CLI).
|
||||||
|
Live-verifiziert: `POST /api/developer/setup` → HTTP 404.
|
||||||
|
- **HOCH – Customer-Login DoS auf Prod (fehlende Migration)**:
|
||||||
|
`portalPasswordMustChange` war im Code, aber prod-DB kannte die
|
||||||
|
Spalte nicht → Prisma warf bei jedem Kunden-Login. Root Cause:
|
||||||
|
in dieser Session wurde `prisma db push` benutzt (kein Migration-
|
||||||
|
File). **Fix**: handgenerierte Migration
|
||||||
|
`20260516173552_portal_password_must_change/migration.sql` (via
|
||||||
|
`prisma migrate diff` + `migrate resolve --applied`). Verifiziert
|
||||||
|
durch shadow-DB-Reset + `migrate deploy`: Spalte landet korrekt
|
||||||
|
in einer frischen DB. `entrypoint.sh` führt `migrate deploy`
|
||||||
|
beim Container-Start bereits aus → Prod-Restart applied jetzt
|
||||||
|
automatisch.
|
||||||
|
- **MITTEL – Prisma-Internals-Leak im Login-Error-Body**: Bei
|
||||||
|
unerwarteten Fehlern (Schema-Bruch, DB-Down) wurde
|
||||||
|
`error.message` direkt zurückgegeben → Tabellen-/Spaltennamen
|
||||||
|
leakten. **Fix**: Whitelist-Filter `safeLoginError()` in
|
||||||
|
`auth.controller.ts`: nur bekannte Messages
|
||||||
|
(`'Ungültige Anmeldedaten'`, `'E-Mail und Passwort
|
||||||
|
erforderlich'`) werden durchgereicht, alles andere wird zu
|
||||||
|
generischem `'Anmeldung fehlgeschlagen'` und das Original
|
||||||
|
landet im Server-Log. Greift für Mitarbeiter- UND Portal-
|
||||||
|
Login. Live-verifiziert: Spalte testweise gedropped → Client
|
||||||
|
sieht generisch, Server-Log enthält Original.
|
||||||
|
|
||||||
- [x] **🔐 Einmalpasswort-Flow für Portal-Credentials**
|
- [x] **🔐 Einmalpasswort-Flow für Portal-Credentials**
|
||||||
- **Intention**: Wenn wir Zugangsdaten per E-Mail an den Kunden
|
- **Intention**: Wenn wir Zugangsdaten per E-Mail an den Kunden
|
||||||
schicken, kennen wir das Passwort als Admin – das ist solange OK,
|
schicken, kennen wir das Passwort als Admin – das ist solange OK,
|
||||||
|
|||||||
Reference in New Issue
Block a user