35745ce3bb
Live-Pentest gegen Dev-Server + 3 parallele Audit-Agents. 🚨 CRITICAL: /api/uploads/* war ohne Auth erreichbar - express.static('/api/uploads', ...) → jeder konnte mit ratbarer URL sensible PDFs (Kündigungsbestätigungen, Ausweise, Bankkarten, Vollmachten) ziehen. Live-verifiziert: 23-KB-PDF eines echten Kunden ohne Login geladen. - Fix: authenticate-Middleware vor static-Handler (req.query.token unterstützung war schon da, jetzt aktiv genutzt). - Frontend: utils/fileUrl.ts hängt JWT als ?token=... an. 24 direkte /api${...Path}-URLs in 5 Dateien per Skript migriert (CustomerDetail, ContractDetail, InvoicesSection, PdfTemplates, GDPRDashboard). 🚨 HIGH: Login-Timing User-Enumeration - bcrypt.compare wurde nur bei existierenden Usern ausgeführt → 110ms vs 10ms Differenz, Email-Enumeration trivial messbar. - Fix: Dummy-bcrypt-compare bei invalid user (Cost 12). Plus Lazy- Rehash bei erfolgreichem Login: alte Cost-10-Hashes (z.B. admin aus Installation) werden auf BCRYPT_COST upgraded, damit Dummy- und Echt-Hash-Cost zusammenpassen. - Live-verifiziert nach Admin-Rehash: 422ms (invalid) vs 423ms (valid) – Side-Channel dicht. 🚨 HIGH: XSS via Privacy-Policy/Imprint-HTML - 4 Frontend-Seiten renderten Backend-HTML ohne DOMPurify (PortalPrivacy, ConsentPage, PortalWebsitePrivacy, PortalImprint). Admin-eingegebene <script>-Tags wären bei jedem Portal-Kunden- Besuch ausgeführt worden – auch auf der öffentlichen Consent-Seite. - Fix: DOMPurify.sanitize mit strikter FORBID_TAGS/ATTR Config. 🛡 HIGH: IDOR-Härtung an Upload-/Document-Endpoints - canAccessContract jetzt in: uploadContractDocument, deleteContractDocument, handleContractDocumentUpload (Kündigungs- Letter+Confirmation), handleContractDocumentDelete, saveAttachmentAsContractDocument. - Defense-in-Depth: aktuell durch requirePermission abgesichert, schützt auch gegen künftige Staff-Scoping-Rollen. Offen für v1.1: - Per-File-Ownership-Check für /api/uploads (Kontroll-Lookup welche Ressource zur Datei gehört) - TipTap-Link-Tool javascript:-Protokoll blockieren - Prisma-Error-Messages in Admin-Endpoints generisch sanitisieren Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
194 lines
7.3 KiB
TypeScript
194 lines
7.3 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import { gdprApi } from '../../services/api';
|
|
import type { ConsentType, ConsentStatus, CustomerConsent } from '../../types';
|
|
import {
|
|
Shield,
|
|
ShieldCheck,
|
|
ShieldX,
|
|
ShieldAlert,
|
|
FileDown,
|
|
CheckCircle2,
|
|
} from 'lucide-react';
|
|
import Card from '../../components/ui/Card';
|
|
import DOMPurify from 'dompurify';
|
|
|
|
const SANITIZE_OPTIONS = {
|
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
|
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'formaction'],
|
|
};
|
|
|
|
const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
|
|
DATA_PROCESSING: {
|
|
label: 'Datenverarbeitung',
|
|
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
|
|
},
|
|
MARKETING_EMAIL: {
|
|
label: 'Elektronisches Marketing',
|
|
description: 'Zusendung von Werbung und Angeboten über elektronische Kommunikationswege (E-Mail, Messenger etc.)',
|
|
},
|
|
MARKETING_PHONE: {
|
|
label: 'Telefonmarketing',
|
|
description: 'Kontaktaufnahme zu Werbezwecken per Telefon',
|
|
},
|
|
DATA_SHARING_PARTNER: {
|
|
label: 'Datenweitergabe an Partner',
|
|
description: 'Weitergabe Ihrer Daten an ausgewählte Partnerunternehmen',
|
|
},
|
|
};
|
|
|
|
export default function PortalPrivacy() {
|
|
const { user } = useAuth();
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['my-privacy'],
|
|
queryFn: () => gdprApi.getMyPrivacy(),
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ consentType, status }: { consentType: ConsentType; status: ConsentStatus }) =>
|
|
gdprApi.updateConsent(user!.customerId!, consentType, { status, source: 'portal' }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['my-privacy'] });
|
|
queryClient.invalidateQueries({ queryKey: ['my-consent-status'] });
|
|
},
|
|
});
|
|
|
|
const handleToggle = (consent: CustomerConsent) => {
|
|
const newStatus: ConsentStatus = consent.status === 'GRANTED' ? 'WITHDRAWN' : 'GRANTED';
|
|
updateMutation.mutate({ consentType: consent.consentType, status: newStatus });
|
|
};
|
|
|
|
const getStatusIcon = (status: ConsentStatus) => {
|
|
switch (status) {
|
|
case 'GRANTED':
|
|
return <ShieldCheck className="w-5 h-5 text-green-500" />;
|
|
case 'WITHDRAWN':
|
|
return <ShieldX className="w-5 h-5 text-red-500" />;
|
|
case 'PENDING':
|
|
return <ShieldAlert className="w-5 h-5 text-yellow-500" />;
|
|
default:
|
|
return <Shield className="w-5 h-5 text-gray-400" />;
|
|
}
|
|
};
|
|
|
|
const getStatusLabel = (status: ConsentStatus) => {
|
|
switch (status) {
|
|
case 'GRANTED':
|
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700">Erteilt</span>;
|
|
case 'WITHDRAWN':
|
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700">Widerrufen</span>;
|
|
case 'PENDING':
|
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Ausstehend</span>;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <div className="text-center py-8 text-gray-500">Laden...</div>;
|
|
}
|
|
|
|
const consents = data?.data?.consents || [];
|
|
const privacyPolicyHtml = data?.data?.privacyPolicyHtml || '';
|
|
const allGranted = consents.every((c) => c.status === 'GRANTED');
|
|
const token = localStorage.getItem('token');
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Shield className="w-6 h-6 text-blue-600" />
|
|
<h1 className="text-2xl font-bold">Datenschutz</h1>
|
|
</div>
|
|
|
|
{/* Erfolgs-Banner wenn alle erteilt */}
|
|
{allGranted && (
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6 flex items-center gap-3">
|
|
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
|
|
<p className="text-sm text-green-700">
|
|
Sie haben allen Einwilligungen zugestimmt. Vielen Dank!
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Einwilligungen */}
|
|
<Card title="Ihre Einwilligungen" className="mb-6">
|
|
<p className="text-sm text-gray-500 mb-4">
|
|
Hier können Sie Ihre Datenschutz-Einwilligungen verwalten. Alle Einwilligungen sind erforderlich, damit wir Sie beraten können.
|
|
</p>
|
|
<div className="space-y-3">
|
|
{consents.map((consent) => {
|
|
const typeInfo = CONSENT_TYPE_LABELS[consent.consentType] || { label: consent.consentType, description: '' };
|
|
|
|
return (
|
|
<div key={consent.consentType} className="border rounded-lg p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-3">
|
|
{getStatusIcon(consent.status)}
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-medium">{typeInfo.label}</h4>
|
|
{getStatusLabel(consent.status)}
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-0.5">{typeInfo.description}</p>
|
|
{consent.grantedAt && consent.status === 'GRANTED' && (
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
Erteilt am {new Date(consent.grantedAt).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => handleToggle(consent)}
|
|
disabled={updateMutation.isPending}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
consent.status === 'GRANTED'
|
|
? 'bg-green-500'
|
|
: 'bg-gray-300'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
consent.status === 'GRANTED' ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{updateMutation.isError && (
|
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
|
Fehler beim Speichern. Bitte versuchen Sie es erneut.
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* 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>
|
|
</div>
|
|
<div
|
|
className="prose prose-sm max-w-none"
|
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
|
|
/>
|
|
</Card>
|
|
|
|
</div>
|
|
);
|
|
}
|