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);
+21 -11
View File
@@ -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<string | null>(null);
useEffect(() => {
let cancelled = false;
authApi.getDownloadToken().then((t) => { if (!cancelled) setPdfToken(t); });
return () => { cancelled = true; };
}, []);
return (
<div>
@@ -172,15 +180,17 @@ export default function PortalPrivacy() {
{/* Datenschutzerklärung */}
<Card title="Datenschutzerklärung" className="mb-6">
<div className="flex justify-end mb-4">
<a
href={`${gdprApi.getMyPrivacyPdfUrl}?token=${token}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
>
<FileDown className="w-4 h-4" />
Als PDF herunterladen
</a>
{pdfToken && (
<a
href={`${gdprApi.getMyPrivacyPdfUrl}?token=${pdfToken}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
>
<FileDown className="w-4 h-4" />
Als PDF herunterladen
</a>
)}
</div>
<div
className="prose prose-sm max-w-none"
+5 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { auditLogApi, AuditLogSearchParams, getAccessToken } from '../../services/api';
import { auditLogApi, AuditLogSearchParams, authApi } from '../../services/api';
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -300,8 +300,9 @@ export default function AuditLogs() {
const handleExport = async (format: 'json' | 'csv') => {
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(
+23 -10
View File
@@ -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<string, CrmField[]>);
const [highlightedField, setHighlightedField] = useState<string | null>(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<string | null>(null);
useEffect(() => {
let cancelled = false;
authApi.getDownloadToken().then((t) => {
if (!cancelled) setPreviewToken(t);
});
return () => { cancelled = true; };
}, [template.id]);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex">
@@ -275,11 +285,13 @@ function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClo
<span>PDF-Vorschau mit Feldnamen</span>
<span className="text-gray-400 text-xs">[Feldname] zeigt wo das Feld in der PDF liegt</span>
</div>
<iframe
src={`/api/pdf-templates/${template.id}/preview?token=${getAccessToken() || ''}`}
className="flex-1 w-full bg-white"
title="PDF Vorschau mit Feldnamen"
/>
{previewToken && (
<iframe
src={`/api/pdf-templates/${template.id}/preview?token=${previewToken}`}
className="flex-1 w-full bg-white"
title="PDF Vorschau mit Feldnamen"
/>
)}
</div>
{/* Zuordnung rechts */}
@@ -428,11 +440,12 @@ function TestPreviewModal({ template, onClose }: { template: PdfTemplate; onClos
});
const contracts: Contract[] = contractsData?.data || [];
const token = getAccessToken();
const handleGenerate = () => {
const handleGenerate = async () => {
if (!selectedContractId) return;
const url = `${pdfTemplateApi.generateUrl(template.id, selectedContractId)}?token=${token || ''}`;
// Kurzlebigen Download-Token (60s) statt langlebigem Access-JWT in der URL.
const downloadToken = await authApi.getDownloadToken();
const url = `${pdfTemplateApi.generateUrl(template.id, selectedContractId)}?token=${downloadToken ?? ''}`;
window.open(url, '_blank');
};
+12
View File
@@ -131,6 +131,18 @@ export const authApi = {
const res = await api.post<ApiResponse<void>>('/auth/change-initial-portal-password', { newPassword });
return res.data;
},
// Kurzlebiger Download-Token (60s) für URL-basierte Aufrufe wie
// iframe-PDF-Preview oder window.open mit ?token=. Selbst wenn dieser
// Token in einem Access-Log oder der Browser-History landet, ist er nach
// einer Minute wertlos.
getDownloadToken: async (): Promise<string | null> => {
try {
const res = await api.post<ApiResponse<{ token: string }>>('/auth/download-token');
return res.data?.data?.token || null;
} catch {
return null;
}
},
};
// Customers