diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index feeec18f..8b4aaffa 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -406,6 +406,36 @@ export async function register(req: Request, res: Response): Promise { } } +// 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 { + 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. diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 1291deb0..cb50387f 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -1096,9 +1096,10 @@ export async function setPortalPassword(req: Request, res: Response): Promise { +export async function getPortalPassword(req: AuthRequest, res: Response): Promise { 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 diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 14b69f3f..8b822176 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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; } diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 2cb43b5c..03de2948 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -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; diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 693b78df..5f9c8558 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -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; diff --git a/docs/todo.md b/docs/todo.md index 87d9fb29..58cd4cb1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,37 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🚨 Pentest Runde 7 – Hit-List durchgegangen + kurzlebige Download-Tokens** + - **Credential-Endpoints** (Contracts password/internet/sip/simcard + + Stressfrei mailbox/send/reset-password): ALLE bereits durch + `canAccessContract`/`canAccessStressfreiEmail` gesichert – keine + Lücke gefunden. + - **`GET /customers/:id/portal/password`** (Klartext-Portal-Passwort- + Abruf): hatte KEINEN `canAccessCustomer`-Check. Fix: eingefügt. + Defense in depth gegen versehentlich falsch vergebene + `customers:update`-Permission. + - **Admin-Funktionen** (factory-reset, developer/*, audit-logs/rehash, + audit-logs/customer): alle durch admin-level Permissions + (`settings:update`, `developer:access`, `audit:admin`, `audit:read`) + geschützt – Portal-User haben diese nicht. + - **Token-in-URL (NIEDRIG)**: Langlebige Access-JWTs landeten als + `?token=` in URLs für PDF-iframe, Audit-Log-Export, PDF-Generate + und Portal-Privacy-PDF → nginx-Access-Logs, Browser-History, + Referer-Header. + * **Neuer Mechanismus**: `POST /api/auth/download-token` liefert + ein kurzlebiges JWT mit `type: 'download'` und `exp: 60s`. + * Auth-Middleware akzeptiert `type: 'download'` AUSSCHLIESSLICH + via `?token=` Query, niemals als Bearer-Header. So kann ein in + Logs geleaktes Download-Token nicht für reguläre API-Aufrufe + missbraucht werden. + * Frontend-Migration: 4 Stellen umgestellt (Audit-Log-Export, + PDF-Template-Preview, PDF-Generate von ContractDetail + Modal, + Portal-Privacy-PDF). `fileUrl` und `getAttachmentUrl` sind + synchron und in vielen Components verstreut – Migration dieser + bleibt als Folge-Aufgabe. + * Live-verifiziert: Download-Token = 1773 Zeichen, type=download, + exp-iat=60s, als Header → 401, als ?token= → 200. + - [x] **🚨 Pentest Runde 6 – Sammelfix + Strukturelles Audit (8 Findings + Audit-Sweep)** - **KRITISCH-01 `GET /emails/:id/thread`**: kein Owner-Check → Portal-Kunde konnte alle Mail-Threads durchsuchen. Fix: diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index 120ed0d0..e7cc76ac 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -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); diff --git a/frontend/src/pages/portal/PortalPrivacy.tsx b/frontend/src/pages/portal/PortalPrivacy.tsx index 857f48ef..61b148b9 100644 --- a/frontend/src/pages/portal/PortalPrivacy.tsx +++ b/frontend/src/pages/portal/PortalPrivacy.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuth } from '../../context/AuthContext'; -import { gdprApi, getAccessToken } from '../../services/api'; +import { gdprApi, authApi } from '../../services/api'; import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types'; import { Shield, @@ -93,7 +94,14 @@ export default function PortalPrivacy() { const consents = data?.data?.consents || []; const privacyPolicyHtml = data?.data?.privacyPolicyHtml || ''; const allGranted = consents.every((c) => c.status === 'GRANTED'); - const token = getAccessToken(); + + // Kurzlebigen Download-Token (60s) für den PDF-Link. + const [pdfToken, setPdfToken] = useState(null); + useEffect(() => { + let cancelled = false; + authApi.getDownloadToken().then((t) => { if (!cancelled) setPdfToken(t); }); + return () => { cancelled = true; }; + }, []); return (
@@ -172,15 +180,17 @@ export default function PortalPrivacy() { {/* Datenschutzerklärung */}
{ try { if (format === 'csv') { - // CSV direkt als Download - const token = getAccessToken(); + // CSV direkt als Download – mit kurzlebigem Download-Token (60s), + // damit das langlebige Access-JWT nicht in Logs/Referer/History landet. + const downloadToken = await authApi.getDownloadToken(); const params = new URLSearchParams(); params.set('format', 'csv'); if (filters.action) params.set('action', filters.action); @@ -309,7 +310,7 @@ export default function AuditLogs() { if (filters.resourceType) params.set('resourceType', filters.resourceType); if (filters.startDate) params.set('startDate', filters.startDate); if (filters.endDate) params.set('endDate', filters.endDate); - window.open(`/api/audit-logs/export?${params}&token=${token}`, '_blank'); + window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank'); } else { const result = await auditLogApi.export({ ...filters, format }); const blob = new Blob( diff --git a/frontend/src/pages/settings/PdfTemplates.tsx b/frontend/src/pages/settings/PdfTemplates.tsx index 84f1ef48..b3341db4 100644 --- a/frontend/src/pages/settings/PdfTemplates.tsx +++ b/frontend/src/pages/settings/PdfTemplates.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { pdfTemplateApi, contractApi, getAccessToken } from '../../services/api'; +import { pdfTemplateApi, contractApi, authApi } from '../../services/api'; import type { PdfTemplate, CrmField, Contract } from '../../types'; import Card from '../../components/ui/Card'; import Button from '../../components/ui/Button'; @@ -266,6 +266,16 @@ function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClo }, {} as Record); const [highlightedField, setHighlightedField] = useState(null); + // Kurzlebigen Download-Token (60s) für die iframe-URL holen, damit das + // langlebige Access-JWT nicht in Browser-History/Referer landet. + const [previewToken, setPreviewToken] = useState(null); + useEffect(() => { + let cancelled = false; + authApi.getDownloadToken().then((t) => { + if (!cancelled) setPreviewToken(t); + }); + return () => { cancelled = true; }; + }, [template.id]); return (
@@ -275,11 +285,13 @@ function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClo PDF-Vorschau mit Feldnamen [Feldname] zeigt wo das Feld in der PDF liegt
-