Security-Hardening Runde 5: Hack-Das-Ding (DSGVO-GAU + Timing + XSS)
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>
This commit is contained in:
@@ -9,6 +9,7 @@ import Select from '../ui/Select';
|
||||
import Badge from '../ui/Badge';
|
||||
import { invoiceApi } from '../../services/api';
|
||||
import type { Invoice, InvoiceType } from '../../types';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
|
||||
const invoiceTypeLabels: Record<InvoiceType, string> = {
|
||||
INTERIM: 'Zwischenrechnung',
|
||||
@@ -120,7 +121,7 @@ export default function InvoicesSection({
|
||||
{invoice.documentPath && (
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/api${invoice.documentPath}`}
|
||||
href={fileUrl(invoice.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||
@@ -129,7 +130,7 @@ export default function InvoicesSection({
|
||||
<Eye className="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href={`/api${invoice.documentPath}`}
|
||||
href={fileUrl(invoice.documentPath)}
|
||||
download
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||
title="Download"
|
||||
|
||||
@@ -19,6 +19,7 @@ import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
import { formatDate } from '../../utils/dateFormat';
|
||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
|
||||
const typeLabels: Record<ContractType, string> = {
|
||||
ELECTRICITY: 'Strom',
|
||||
@@ -2034,7 +2035,7 @@ export default function ContractDetail() {
|
||||
{c.cancellationLetterPath ? (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={`/api${c.cancellationLetterPath}`}
|
||||
href={fileUrl(c.cancellationLetterPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -2043,7 +2044,7 @@ export default function ContractDetail() {
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${c.cancellationLetterPath}`}
|
||||
href={fileUrl(c.cancellationLetterPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -2091,7 +2092,7 @@ export default function ContractDetail() {
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={`/api${c.cancellationConfirmationPath}`}
|
||||
href={fileUrl(c.cancellationConfirmationPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -2100,7 +2101,7 @@ export default function ContractDetail() {
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${c.cancellationConfirmationPath}`}
|
||||
href={fileUrl(c.cancellationConfirmationPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -2177,7 +2178,7 @@ export default function ContractDetail() {
|
||||
{c.cancellationLetterOptionsPath ? (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={`/api${c.cancellationLetterOptionsPath}`}
|
||||
href={fileUrl(c.cancellationLetterOptionsPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -2186,7 +2187,7 @@ export default function ContractDetail() {
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${c.cancellationLetterOptionsPath}`}
|
||||
href={fileUrl(c.cancellationLetterOptionsPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -2234,7 +2235,7 @@ export default function ContractDetail() {
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={`/api${c.cancellationConfirmationOptionsPath}`}
|
||||
href={fileUrl(c.cancellationConfirmationOptionsPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -2243,7 +2244,7 @@ export default function ContractDetail() {
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${c.cancellationConfirmationOptionsPath}`}
|
||||
href={fileUrl(c.cancellationConfirmationOptionsPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -3310,7 +3311,7 @@ function ContractDocumentsSection({
|
||||
{doc.documentType}
|
||||
</span>
|
||||
<a
|
||||
href={`/api${doc.documentPath}`}
|
||||
href={fileUrl(doc.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
@@ -3327,7 +3328,7 @@ function ContractDocumentsSection({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/api${doc.documentPath}`}
|
||||
href={fileUrl(doc.documentPath)}
|
||||
download
|
||||
className="text-gray-400 hover:text-blue-600"
|
||||
title="Herunterladen"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { formatDate } from '../../utils/dateFormat';
|
||||
import { getContractTypeInfo } from '../../utils/contractInfo';
|
||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
|
||||
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
|
||||
const { id } = useParams();
|
||||
@@ -564,7 +565,7 @@ function BusinessDataCard({
|
||||
{customer.businessRegistrationPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/api${customer.businessRegistrationPath}`}
|
||||
href={fileUrl(customer.businessRegistrationPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -573,7 +574,7 @@ function BusinessDataCard({
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${customer.businessRegistrationPath}`}
|
||||
href={fileUrl(customer.businessRegistrationPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -615,7 +616,7 @@ function BusinessDataCard({
|
||||
{customer.commercialRegisterPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/api${customer.commercialRegisterPath}`}
|
||||
href={fileUrl(customer.commercialRegisterPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -624,7 +625,7 @@ function BusinessDataCard({
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${customer.commercialRegisterPath}`}
|
||||
href={fileUrl(customer.commercialRegisterPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -935,7 +936,7 @@ function BankCardsTab({
|
||||
{card.documentPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/api${card.documentPath}`}
|
||||
href={fileUrl(card.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -944,7 +945,7 @@ function BankCardsTab({
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${card.documentPath}`}
|
||||
href={fileUrl(card.documentPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -1171,7 +1172,7 @@ function DocumentsTab({
|
||||
{doc.documentPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/api${doc.documentPath}`}
|
||||
href={fileUrl(doc.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -1180,7 +1181,7 @@ function DocumentsTab({
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${doc.documentPath}`}
|
||||
href={fileUrl(doc.documentPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -3925,7 +3926,7 @@ function ConsentTab({
|
||||
{customer.privacyPolicyPath ? (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={`/api${customer.privacyPolicyPath}`}
|
||||
href={fileUrl(customer.privacyPolicyPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -3934,7 +3935,7 @@ function ConsentTab({
|
||||
Anzeigen
|
||||
</a>
|
||||
<a
|
||||
href={`/api${customer.privacyPolicyPath}`}
|
||||
href={fileUrl(customer.privacyPolicyPath)}
|
||||
download
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
>
|
||||
@@ -4231,7 +4232,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
||||
{auth.documentPath ? (
|
||||
<>
|
||||
<a
|
||||
href={`/api${auth.documentPath}`}
|
||||
href={fileUrl(auth.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs flex items-center gap-1"
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { gdprApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import { Building } from 'lucide-react';
|
||||
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'],
|
||||
};
|
||||
|
||||
export default function PortalImprint() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
@@ -22,7 +28,7 @@ export default function PortalImprint() {
|
||||
<h1 className="text-2xl font-bold">Impressum</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, SANITIZE_OPTIONS) }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
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: {
|
||||
@@ -178,7 +184,7 @@ export default function PortalPrivacy() {
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { gdprApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import { Shield } from 'lucide-react';
|
||||
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'],
|
||||
};
|
||||
|
||||
export default function PortalWebsitePrivacy() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
@@ -22,7 +28,7 @@ export default function PortalWebsitePrivacy() {
|
||||
<h1 className="text-2xl font-bold">Datenschutzerklärung</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, SANITIZE_OPTIONS) }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { publicApi } from '../../services/api';
|
||||
import { formatDate } from '../../utils/dateFormat';
|
||||
import { Shield, CheckCircle2, FileDown, Loader2 } from 'lucide-react';
|
||||
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'],
|
||||
};
|
||||
|
||||
export default function ConsentPage() {
|
||||
const { hash } = useParams<{ hash: string }>();
|
||||
@@ -150,7 +156,7 @@ export default function ConsentPage() {
|
||||
</div>
|
||||
<div
|
||||
className="p-6 prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Select from '../../components/ui/Select';
|
||||
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: 'Alle Status' },
|
||||
@@ -362,7 +363,7 @@ export default function GDPRDashboard() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/api/uploads/${request.proofDocument}`, '_blank')}
|
||||
onClick={() => window.open(fileUrl(`/uploads/${request.proofDocument}`), '_blank')}
|
||||
title="Löschnachweis anzeigen"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-blue-500" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import Input from '../../components/ui/Input';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
|
||||
export default function PdfTemplates() {
|
||||
const navigate = useNavigate();
|
||||
@@ -95,7 +96,7 @@ export default function PdfTemplates() {
|
||||
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
|
||||
<Play className="w-4 h-4 text-green-500" />
|
||||
</Button>
|
||||
<a href={`/api${t.templatePath}`} target="_blank" rel="noopener noreferrer">
|
||||
<a href={fileUrl(t.templatePath)} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="sm" title="Leere Vorlage anzeigen">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
||||
*
|
||||
* `/api/uploads/*` läuft hinter authenticate-Middleware, aber <a href> und
|
||||
* window.open senden keinen Authorization-Header. Darum hängen wir das JWT
|
||||
* als Query-Parameter an. Die authenticate-Middleware akzeptiert
|
||||
* `?token=<jwt>` neben dem Header.
|
||||
*
|
||||
* Trade-off: Tokens in URLs landen potenziell in Logs/Referrer. Für eine
|
||||
* saubere Lösung (kurzlebige Download-Tokens) wäre ein separater Endpoint
|
||||
* nötig – TODO für v1.1.
|
||||
*/
|
||||
export function fileUrl(path: string | null | undefined): string {
|
||||
if (!path) return '';
|
||||
const token = localStorage.getItem('token');
|
||||
const base = `/api${path.startsWith('/') ? path : '/' + path}`;
|
||||
if (!token) return base;
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
return `${base}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user