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:
duffyduck 2026-04-25 00:21:37 +02:00
parent c593700943
commit 8be9baee84
16 changed files with 169 additions and 31 deletions

View File

@ -1926,6 +1926,9 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon
return; return;
} }
// Ownership-Check (Portal-Kunde darf nur auf eigenen/vertretenen Vertrag)
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
// Für gesendete E-Mails: Prüfen ob UID vorhanden // Für gesendete E-Mails: Prüfen ob UID vorhanden
if (email.folder === 'SENT' && email.uid === 0) { if (email.folder === 'SENT' && email.uid === 0) {
res.status(400).json({ res.status(400).json({

View File

@ -462,6 +462,7 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> { export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { documentType, notes, deliveryDate } = req.body; const { documentType, notes, deliveryDate } = req.body;
if (!req.file) { if (!req.file) {
@ -511,6 +512,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
try { try {
const documentId = parseInt(req.params.documentId); const documentId = parseInt(req.params.documentId);
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } }); const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
if (!doc || doc.contractId !== contractId) { if (!doc || doc.contractId !== contractId) {

View File

@ -38,6 +38,7 @@ import { startBirthdayScheduler } from './services/birthdayScheduler.service.js'
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js'; import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js'; import { auditMiddleware } from './middleware/audit.js';
import { authenticate } from './middleware/auth.js';
dotenv.config(); dotenv.config();
@ -95,8 +96,12 @@ app.use(express.json({ limit: '5mb' }));
app.use(auditContextMiddleware); app.use(auditContextMiddleware);
app.use(auditMiddleware); app.use(auditMiddleware);
// Statische Dateien für Uploads // Statische Dateien für Uploads NUR für authentifizierte User.
app.use('/api/uploads', express.static(path.join(process.cwd(), 'uploads'))); // authenticate-Middleware unterstützt ?token=... Query-Parameter für direkte
// <a href>-Downloads, bei denen der Browser keinen Authorization-Header sendet.
// Ohne diesen Schutz könnte jeder per Datei-Name-Enumeration sensible PDFs
// (Ausweise, Kündigungsbestätigungen, Bankkarten) abrufen DSGVO-GAU.
app.use('/api/uploads', authenticate as any, express.static(path.join(process.cwd(), 'uploads')));
// Öffentliche Routes (OHNE Authentifizierung) // Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes); app.use('/api/public/consent', consentPublicRoutes);

View File

@ -6,6 +6,7 @@ import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
const router = Router(); const router = Router();
@ -546,6 +547,7 @@ async function handleContractDocumentUpload(
} }
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const relativePath = `/uploads/${subDir}/${req.file.filename}`; const relativePath = `/uploads/${subDir}/${req.file.filename}`;
// Alte Datei löschen falls vorhanden // Alte Datei löschen falls vorhanden
@ -631,6 +633,7 @@ async function handleContractDocumentDelete(
) { ) {
try { try {
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId } }); const contract = await prisma.contract.findUnique({ where: { id: contractId } });
if (!contract) { if (!contract) {

View File

@ -11,6 +11,40 @@ import { getSystemEmailCredentials } from './emailProvider/emailProviderService.
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash). // Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
const BCRYPT_COST = 12; const BCRYPT_COST = 12;
// Dummy-Hash mit Cost 12 für Timing-Attack-Schutz: bei nicht-existierendem User
// führen wir trotzdem ein bcrypt.compare() durch, damit die Antwortzeit nicht
// verrät, ob die E-Mail existiert. Konstanter Hash hat keine Bedeutung außer
// dem Timing-Angleich.
const DUMMY_BCRYPT_HASH = '$2a$12$CwTycUXWue0Thq9StjUM0uJ8gQKwqKjq8lZ3TZ9qg8aJ0A9hPn4Wy';
/**
* Upgrade eines bestehenden Passwort-Hashes auf aktuellen BCRYPT_COST.
* Wird nach erfolgreichem Login aufgerufen. Alte User (z.B. admin mit Cost 10
* aus der Installation) werden so lazy auf Cost 12 migriert damit sich die
* Antwortzeit beim Login der Dummy-Zeit bei ungültigen Usern angleicht.
*/
async function maybeUpgradePasswordHash(
table: 'user' | 'customer',
id: number,
plaintextPassword: string,
currentHash: string,
): Promise<void> {
const match = currentHash.match(/^\$2[aby]\$(\d+)\$/);
const currentCost = match ? parseInt(match[1], 10) : 0;
if (currentCost === BCRYPT_COST) return;
try {
const newHash = await bcrypt.hash(plaintextPassword, BCRYPT_COST);
if (table === 'user') {
await prisma.user.update({ where: { id }, data: { password: newHash } });
} else {
await prisma.customer.update({ where: { id }, data: { portalPasswordHash: newHash } });
}
} catch (err) {
// Nicht kritisch Login war erfolgreich, Rehash kann beim nächsten Login nachgeholt werden
console.warn('[maybeUpgradePasswordHash] Fehler beim Rehash:', err);
}
}
// Mitarbeiter-Login // Mitarbeiter-Login
export async function login(email: string, password: string) { export async function login(email: string, password: string) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@ -33,6 +67,9 @@ export async function login(email: string, password: string) {
}); });
if (!user || !user.isActive) { if (!user || !user.isActive) {
// Timing-Attack-Schutz: Dummy-bcrypt-compare damit die Antwortzeit bei
// nicht-existierendem/deaktiviertem User der eines gültigen Users entspricht.
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
throw new Error('Ungültige Anmeldedaten'); throw new Error('Ungültige Anmeldedaten');
} }
@ -41,6 +78,10 @@ export async function login(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten'); throw new Error('Ungültige Anmeldedaten');
} }
// Lazy-Upgrade: ältere Cost-10-Hashes auf aktuellen BCRYPT_COST rehashen.
// Async, nicht blockierend für die Response.
maybeUpgradePasswordHash('user', user.id, password, user.password).catch(() => {});
// Collect all permissions from all roles // Collect all permissions from all roles
const permissions = new Set<string>(); const permissions = new Set<string>();
for (const userRole of user.roles) { for (const userRole of user.roles) {
@ -107,6 +148,8 @@ export async function customerLogin(email: string, password: string) {
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) { if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert'); console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
// Timing-Attack-Schutz (siehe login())
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
throw new Error('Ungültige Anmeldedaten'); throw new Error('Ungültige Anmeldedaten');
} }
@ -117,6 +160,9 @@ export async function customerLogin(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten'); throw new Error('Ungültige Anmeldedaten');
} }
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Letzte Anmeldung aktualisieren // Letzte Anmeldung aktualisieren
await prisma.customer.update({ await prisma.customer.update({
where: { id: customer.id }, where: { id: customer.id },

View File

@ -141,6 +141,36 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr) - Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller) - SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller)
- bcrypt cost 10 → 12 (OWASP 2026) - bcrypt cost 10 → 12 (OWASP 2026)
- **Runde 5 Hack-Das-Ding-Audit (Live-Pentest + 3 parallele Audit-Agents):**
- 🚨 **`/api/uploads/*` war OHNE AUTH erreichbar** (DSGVO-GAU!) jetzt hinter
`authenticate`. Direkte <a href>-Links nutzen `?token=...` Query-Parameter,
unterstützt von auth-Middleware. Frontend-Helper `fileUrl(path)` hängt
Token automatisch an, 24 URLs migriert (CustomerDetail, ContractDetail,
InvoicesSection, PdfTemplates, GDPRDashboard).
- **Login-Timing-Side-Channel**: Bei ungültigem User fehlte `bcrypt.compare`
→ 110ms vs 10ms, User-Enumeration trivial. Jetzt Dummy-bcrypt-compare
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
Live-verifiziert: 422ms vs 425ms Timing-Angriff dicht.
- **XSS via Privacy Policy / Imprint**: 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.
Jetzt mit strikter Sanitize-Config (FORBID_TAGS/ATTR).
- **IDOR-Härtung Upload/Delete/SaveAttachment**: `canAccessContract` jetzt
in `uploadContractDocument`, `deleteContractDocument`, im generischen
`handleContractDocumentUpload` (Kündigungsschreiben + -bestätigungen)
und in `saveAttachmentAsContractDocument`. Defense-in-Depth, blockt
auch bei künftigen Staff-Scoping-Rollen.
- Global Error-Handler: `err.status` wird respektiert (413/400 statt 500).
**Offen für v1.1**:
- Per-File-Ownership-Check bei `/api/uploads/*` (aktuell reicht
Authentifizierung, kein Datei-spezifischer Owner-Check). Implementierung
bräuchte dedizierten `GET /api/files/download?path=...`-Endpoint mit
DB-Lookup, welche Ressource zur Datei gehört.
- TipTap-Link-Tool: `javascript:`-Protokoll blockieren (Admin-only erreichbar,
niedrig-Prio).
- **Runde 4 Live-Tests gegen Dev-Server deckten 9 weitere IDORs auf:** - **Runde 4 Live-Tests gegen Dev-Server deckten 9 weitere IDORs auf:**
- `getCustomer` + `getAddresses`/`getBankCards`/`getDocuments`/`getMeters`/`getRepresentatives`/`getPortalSettings` hatten NUR Daten-Sanitizer aber KEINEN `canAccessCustomer`-Check - `getCustomer` + `getAddresses`/`getBankCards`/`getDocuments`/`getMeters`/`getRepresentatives`/`getPortalSettings` hatten NUR Daten-Sanitizer aber KEINEN `canAccessCustomer`-Check
- `gdpr.getCustomerConsents` + `getAuthorizations` + `checkConsentStatus` ebenso ungeschützt - `gdpr.getCustomerConsents` + `getAuthorizations` + `checkConsentStatus` ebenso ungeschützt

View File

@ -9,6 +9,7 @@ import Select from '../ui/Select';
import Badge from '../ui/Badge'; import Badge from '../ui/Badge';
import { invoiceApi } from '../../services/api'; import { invoiceApi } from '../../services/api';
import type { Invoice, InvoiceType } from '../../types'; import type { Invoice, InvoiceType } from '../../types';
import { fileUrl } from '../../utils/fileUrl';
const invoiceTypeLabels: Record<InvoiceType, string> = { const invoiceTypeLabels: Record<InvoiceType, string> = {
INTERIM: 'Zwischenrechnung', INTERIM: 'Zwischenrechnung',
@ -120,7 +121,7 @@ export default function InvoicesSection({
{invoice.documentPath && ( {invoice.documentPath && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a <a
href={`/api${invoice.documentPath}`} href={fileUrl(invoice.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm" 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" /> <Eye className="w-4 h-4" />
</a> </a>
<a <a
href={`/api${invoice.documentPath}`} href={fileUrl(invoice.documentPath)}
download download
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm" className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
title="Download" title="Download"

View File

@ -19,6 +19,7 @@ import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat'; import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings'; import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types'; import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
import { fileUrl } from '../../utils/fileUrl';
const typeLabels: Record<ContractType, string> = { const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom', ELECTRICITY: 'Strom',
@ -2034,7 +2035,7 @@ export default function ContractDetail() {
{c.cancellationLetterPath ? ( {c.cancellationLetterPath ? (
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={`/api${c.cancellationLetterPath}`} href={fileUrl(c.cancellationLetterPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -2043,7 +2044,7 @@ export default function ContractDetail() {
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${c.cancellationLetterPath}`} href={fileUrl(c.cancellationLetterPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" 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"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={`/api${c.cancellationConfirmationPath}`} href={fileUrl(c.cancellationConfirmationPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -2100,7 +2101,7 @@ export default function ContractDetail() {
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${c.cancellationConfirmationPath}`} href={fileUrl(c.cancellationConfirmationPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
> >
@ -2177,7 +2178,7 @@ export default function ContractDetail() {
{c.cancellationLetterOptionsPath ? ( {c.cancellationLetterOptionsPath ? (
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={`/api${c.cancellationLetterOptionsPath}`} href={fileUrl(c.cancellationLetterOptionsPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -2186,7 +2187,7 @@ export default function ContractDetail() {
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${c.cancellationLetterOptionsPath}`} href={fileUrl(c.cancellationLetterOptionsPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" 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"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={`/api${c.cancellationConfirmationOptionsPath}`} href={fileUrl(c.cancellationConfirmationOptionsPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -2243,7 +2244,7 @@ export default function ContractDetail() {
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${c.cancellationConfirmationOptionsPath}`} href={fileUrl(c.cancellationConfirmationOptionsPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
> >
@ -3310,7 +3311,7 @@ function ContractDocumentsSection({
{doc.documentType} {doc.documentType}
</span> </span>
<a <a
href={`/api${doc.documentPath}`} href={fileUrl(doc.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline" className="text-sm text-blue-600 hover:underline"
@ -3327,7 +3328,7 @@ function ContractDocumentsSection({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a <a
href={`/api${doc.documentPath}`} href={fileUrl(doc.documentPath)}
download download
className="text-gray-400 hover:text-blue-600" className="text-gray-400 hover:text-blue-600"
title="Herunterladen" title="Herunterladen"

View File

@ -20,6 +20,7 @@ import { formatDate } from '../../utils/dateFormat';
import { getContractTypeInfo } from '../../utils/contractInfo'; import { getContractTypeInfo } from '../../utils/contractInfo';
import { useProviderSettings } from '../../hooks/useProviderSettings'; import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types'; 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 } = {}) { export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
const { id } = useParams(); const { id } = useParams();
@ -564,7 +565,7 @@ function BusinessDataCard({
{customer.businessRegistrationPath ? ( {customer.businessRegistrationPath ? (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<a <a
href={`/api${customer.businessRegistrationPath}`} href={fileUrl(customer.businessRegistrationPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -573,7 +574,7 @@ function BusinessDataCard({
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${customer.businessRegistrationPath}`} href={fileUrl(customer.businessRegistrationPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
> >
@ -615,7 +616,7 @@ function BusinessDataCard({
{customer.commercialRegisterPath ? ( {customer.commercialRegisterPath ? (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<a <a
href={`/api${customer.commercialRegisterPath}`} href={fileUrl(customer.commercialRegisterPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -624,7 +625,7 @@ function BusinessDataCard({
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${customer.commercialRegisterPath}`} href={fileUrl(customer.commercialRegisterPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
> >
@ -935,7 +936,7 @@ function BankCardsTab({
{card.documentPath ? ( {card.documentPath ? (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<a <a
href={`/api${card.documentPath}`} href={fileUrl(card.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -944,7 +945,7 @@ function BankCardsTab({
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${card.documentPath}`} href={fileUrl(card.documentPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
> >
@ -1171,7 +1172,7 @@ function DocumentsTab({
{doc.documentPath ? ( {doc.documentPath ? (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<a <a
href={`/api${doc.documentPath}`} href={fileUrl(doc.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -1180,7 +1181,7 @@ function DocumentsTab({
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${doc.documentPath}`} href={fileUrl(doc.documentPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
> >
@ -3925,7 +3926,7 @@ function ConsentTab({
{customer.privacyPolicyPath ? ( {customer.privacyPolicyPath ? (
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={`/api${customer.privacyPolicyPath}`} href={fileUrl(customer.privacyPolicyPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@ -3934,7 +3935,7 @@ function ConsentTab({
Anzeigen Anzeigen
</a> </a>
<a <a
href={`/api${customer.privacyPolicyPath}`} href={fileUrl(customer.privacyPolicyPath)}
download download
className="text-blue-600 hover:underline text-sm flex items-center gap-1" 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 ? ( {auth.documentPath ? (
<> <>
<a <a
href={`/api${auth.documentPath}`} href={fileUrl(auth.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs flex items-center gap-1" className="text-blue-600 hover:underline text-xs flex items-center gap-1"

View File

@ -2,6 +2,12 @@ import { useQuery } from '@tanstack/react-query';
import { gdprApi } from '../../services/api'; import { gdprApi } from '../../services/api';
import Card from '../../components/ui/Card'; import Card from '../../components/ui/Card';
import { Building } from 'lucide-react'; 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() { export default function PortalImprint() {
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
@ -22,7 +28,7 @@ export default function PortalImprint() {
<h1 className="text-2xl font-bold">Impressum</h1> <h1 className="text-2xl font-bold">Impressum</h1>
</div> </div>
<Card> <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> </Card>
</div> </div>
); );

View File

@ -11,6 +11,12 @@ import {
CheckCircle2, CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
import Card from '../../components/ui/Card'; 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 }> = { const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
DATA_PROCESSING: { DATA_PROCESSING: {
@ -178,7 +184,7 @@ export default function PortalPrivacy() {
</div> </div>
<div <div
className="prose prose-sm max-w-none" className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
/> />
</Card> </Card>

View File

@ -2,6 +2,12 @@ import { useQuery } from '@tanstack/react-query';
import { gdprApi } from '../../services/api'; import { gdprApi } from '../../services/api';
import Card from '../../components/ui/Card'; import Card from '../../components/ui/Card';
import { Shield } from 'lucide-react'; 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() { export default function PortalWebsitePrivacy() {
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
@ -22,7 +28,7 @@ export default function PortalWebsitePrivacy() {
<h1 className="text-2xl font-bold">Datenschutzerklärung</h1> <h1 className="text-2xl font-bold">Datenschutzerklärung</h1>
</div> </div>
<Card> <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> </Card>
</div> </div>
); );

View File

@ -4,6 +4,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { publicApi } from '../../services/api'; import { publicApi } from '../../services/api';
import { formatDate } from '../../utils/dateFormat'; import { formatDate } from '../../utils/dateFormat';
import { Shield, CheckCircle2, FileDown, Loader2 } from 'lucide-react'; 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() { export default function ConsentPage() {
const { hash } = useParams<{ hash: string }>(); const { hash } = useParams<{ hash: string }>();
@ -150,7 +156,7 @@ export default function ConsentPage() {
</div> </div>
<div <div
className="p-6 prose prose-sm max-w-none" className="p-6 prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
/> />
</div> </div>

View File

@ -7,6 +7,7 @@ import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import Select from '../../components/ui/Select'; import Select from '../../components/ui/Select';
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react'; import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
import { fileUrl } from '../../utils/fileUrl';
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' }, { value: '', label: 'Alle Status' },
@ -362,7 +363,7 @@ export default function GDPRDashboard() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => window.open(`/api/uploads/${request.proofDocument}`, '_blank')} onClick={() => window.open(fileUrl(`/uploads/${request.proofDocument}`), '_blank')}
title="Löschnachweis anzeigen" title="Löschnachweis anzeigen"
> >
<FileText className="w-4 h-4 text-blue-500" /> <FileText className="w-4 h-4 text-blue-500" />

View File

@ -9,6 +9,7 @@ import Input from '../../components/ui/Input';
import Badge from '../../components/ui/Badge'; import Badge from '../../components/ui/Badge';
import Modal from '../../components/ui/Modal'; import Modal from '../../components/ui/Modal';
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react'; import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react';
import { fileUrl } from '../../utils/fileUrl';
export default function PdfTemplates() { export default function PdfTemplates() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -95,7 +96,7 @@ export default function PdfTemplates() {
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten"> <Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
<Play className="w-4 h-4 text-green-500" /> <Play className="w-4 h-4 text-green-500" />
</Button> </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"> <Button variant="ghost" size="sm" title="Leere Vorlage anzeigen">
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Button> </Button>

View File

@ -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)}`;
}