Security-Hardening Runde 11: Pentest Runde 7 (Portal-PW + Download-Tokens)

Hit-List vom Pentester abgearbeitet. Hauptpunkte:

1) Contract/Mail-Credentials (password/internet/sip/simcard, mailbox/send/
   reset-password): ALLE bereits durch canAccess* gesichert, keine Lücke.

2) GET /customers/:id/portal/password (Klartext-Portal-PW-Abruf):
   fehlender canAccessCustomer-Check ergänzt. Defense in depth gegen
   versehentliche customers:update-Permission an Portal/eingeschränkte
   Mitarbeiter.

3) Admin-Endpoints (factory-reset, developer/*, audit-logs/rehash,
   audit-logs/customer): durch admin-Permissions geschützt – Portal-User
   haben diese nicht.

4) Token-in-URL (NIEDRIG): Langlebige Access-JWTs landeten als ?token= in
   URLs für iframe-PDFs, Audit-Export-Window etc. → nginx-Logs +
   Browser-History + Referer.
   Lösung: kurzlebige Download-Tokens.
   - signDownloadToken() liefert JWT mit type='download', exp=60s
   - Auth-Middleware akzeptiert type='download' AUSSCHLIESSLICH via
     ?token=, niemals als Bearer-Header
   - POST /api/auth/download-token Endpoint (authenticated)
   - Frontend: authApi.getDownloadToken() utility
   - 4 Stellen migriert: AuditLog-Export, PdfTemplate-Preview-iframe,
     PdfTemplate-Generate, ContractDetail-PDF-Generate (2x),
     Portal-Privacy-PDF
   - fileUrl/getAttachmentUrl sind synchron + breit gestreut – Migration
     bleibt für Folge-PR

Live-verifiziert:
- Download-Token: 1773 Zeichen, type=download, exp-iat=60s
- als Header → 401 (Falscher Token-Typ), als ?token= → 200
- portal-user (Customer 3) auf customers/2/portal/password → 403

Rate-Limiter-Check: express-rate-limit Fixed-Window, kein Reset bei jedem
Request (Pentester-Klage „Fenster reseted sich" stimmt mit dem Code nicht
überein – wahrscheinlich Retry-After-Misinterpretation). Kein Code-Bug
identifiziert; ggf. später Admin-Override-Endpoint nachrüsten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 00:40:00 +02:00
parent a982795388
commit 69b9a35674
11 changed files with 163 additions and 36 deletions
@@ -406,6 +406,36 @@ export async function register(req: Request, res: Response): Promise<void> {
}
}
// Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL
// brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer
// authentifiziert sich normal per Bearer-Header. Antwort: ein download-
// scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert.
export async function createDownloadToken(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
const payload: any = {
email: req.user.email,
permissions: req.user.permissions,
isCustomerPortal: !!req.user.isCustomerPortal,
};
if (req.user.userId) payload.userId = req.user.userId;
if (req.user.customerId) payload.customerId = req.user.customerId;
if ((req.user as any).representedCustomerIds) {
payload.representedCustomerIds = (req.user as any).representedCustomerIds;
}
const token = authService.signDownloadToken(payload);
res.json({ success: true, data: { token } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Erstellen des Download-Tokens',
} as ApiResponse);
}
}
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
// loggt aus und schickt zurück zum Login.
@@ -1096,9 +1096,10 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
}
}
export async function getPortalPassword(req: Request, res: Response): Promise<void> {
export async function getPortalPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const password = await authService.getCustomerPortalPassword(customerId);
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
+16 -1
View File
@@ -13,12 +13,15 @@ export async function authenticate(
// Token aus Header oder Query-Parameter (für Downloads)
let token: string | null = null;
let tokenSource: 'header' | 'query' | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1];
tokenSource = 'header';
} else if (req.query.token && typeof req.query.token === 'string') {
// Fallback für Downloads: Token als Query-Parameter
token = req.query.token;
tokenSource = 'query';
}
if (!token) {
@@ -38,7 +41,19 @@ export async function authenticate(
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
// zwangsabgemeldet werden.
if (decoded.type && decoded.type !== 'access') {
if (decoded.type === 'refresh') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
// Download-Tokens sind kurzlebig (60s) und dürfen NUR per `?token=`
// genutzt werden, NIE als Bearer-Header. Damit kann ein in einer URL
// geleakter Download-Token nicht für reguläre API-Aufrufe missbraucht
// werden (Pentest Runde 7 NIEDRIG, Token-in-URL-Defense).
if (decoded.type === 'download' && tokenSource !== 'query') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
if (decoded.type && decoded.type !== 'access' && decoded.type !== 'download') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
+3
View File
@@ -19,4 +19,7 @@ router.post('/password-reset/confirm', passwordResetRateLimiter, authController.
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
// Kurzlebiger Download-Token (60s) für ?token=-Aufrufe (PDF/Export-Window)
router.post('/download-token', authenticate, authController.createDownloadToken);
export default router;
+11
View File
@@ -27,6 +27,17 @@ export function signRefreshToken(payload: JwtPayload): string {
});
}
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
// abgerufen, wenn ein Endpoint per `?token=` aufgerufen werden muss
// (z.B. PDF-iframe, Audit-Export-Window). Selbst wenn dieser Token in
// nginx-Access-Logs oder der Browser-History landet, ist er nach
// 60 Sekunden wertlos. Pentest Runde 7 (2026-05-17) NIEDRIG.
export function signDownloadToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'download' }, process.env.JWT_SECRET as string, {
expiresIn: '60s',
});
}
// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
const BCRYPT_COST = 12;