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
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi, pdfTemplateApi, getAccessToken } from '../../services/api';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi, pdfTemplateApi, authApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
@@ -3483,14 +3483,14 @@ function GenerateOrderButton({ contractId }: { contractId: number }) {
if (inputs && (inputs.needsStressfreiEmail || inputs.manualFields.length > 0)) {
setShowInputModal({ templateId, templateName });
} else {
// Direkt generieren (GET-Link)
const token = getAccessToken();
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank');
// Direkt generieren (GET-Link) kurzlebiger Download-Token
const downloadToken = await authApi.getDownloadToken();
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${downloadToken ?? ''}`, '_blank');
}
} catch {
// Fallback: direkt generieren
const token = getAccessToken();
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token || ''}`, '_blank');
const downloadToken = await authApi.getDownloadToken();
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${downloadToken ?? ''}`, '_blank');
}
};
@@ -3561,10 +3561,10 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
const inputs = inputsData?.data;
const handleGenerate = () => {
const token = getAccessToken();
const handleGenerate = async () => {
const downloadToken = await authApi.getDownloadToken();
const params = new URLSearchParams();
params.set('token', token || '');
params.set('token', downloadToken ?? '');
if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId);
for (const [key, value] of Object.entries(manualValues)) {
if (value) params.set(`manual_${key}`, value);