Compare commits
2 Commits
c593700943
...
4e91d96b5b
| Author | SHA1 | Date |
|---|---|---|
|
|
4e91d96b5b | |
|
|
8be9baee84 |
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,24 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
|
||||||
page: page ? parseInt(page as string) : undefined,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
limit: limit ? parseInt(limit as string) : undefined,
|
limit: limit ? parseInt(limit as string) : undefined,
|
||||||
});
|
});
|
||||||
|
let customers = result.customers as any[];
|
||||||
|
|
||||||
|
// Portal-Kunden: Liste auf eigenen + vertretene Kunden einschränken.
|
||||||
|
// Ohne diesen Filter würde der List-Endpoint die komplette Kundendatenbank
|
||||||
|
// an einen einzelnen Portal-Account preisgeben.
|
||||||
|
if (req.user?.isCustomerPortal) {
|
||||||
|
const allowedIds = new Set<number>();
|
||||||
|
if (req.user.customerId) allowedIds.add(req.user.customerId);
|
||||||
|
const represented = (req.user as any).representedCustomerIds || [];
|
||||||
|
for (const id of represented) allowedIds.add(id);
|
||||||
|
customers = customers.filter((c) => allowedIds.has(c.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
|
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
|
||||||
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
||||||
const sanitized = canSeePasswords
|
const sanitized = canSeePasswords
|
||||||
? sanitizeCustomers(result.customers as any)
|
? sanitizeCustomers(customers)
|
||||||
: (result.customers as any[]).map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
|
: customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
|
||||||
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
|
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|
|
||||||
|
|
@ -970,12 +970,27 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
|
||||||
const representativeId = parseInt(req.params.representativeId);
|
const representativeId = parseInt(req.params.representativeId);
|
||||||
const { grant } = req.body;
|
const { grant } = req.body;
|
||||||
|
|
||||||
// Vertreter-Name laden
|
// Validierungen:
|
||||||
const representative = await prisma.customer.findUnique({
|
// 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
|
||||||
where: { id: representativeId },
|
if (representativeId === user.customerId) {
|
||||||
select: { firstName: true, lastName: true },
|
return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' });
|
||||||
|
}
|
||||||
|
// 2) Existenz + aktives Vertreter-Verhältnis in EINEM Lookup prüfen.
|
||||||
|
// Beide Fälle (representative existiert nicht / keine aktive Beziehung)
|
||||||
|
// geben identisch 403, damit ein Angreifer keine Customer-IDs aus der
|
||||||
|
// DB enumerieren kann (kein 404-vs-403-Disclosure).
|
||||||
|
const relation = await prisma.customerRepresentative.findFirst({
|
||||||
|
where: { customerId: user.customerId, representativeId, isActive: true },
|
||||||
|
include: { representative: { select: { firstName: true, lastName: true } } },
|
||||||
});
|
});
|
||||||
const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`;
|
if (!relation) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Kein Vertreter-Verhältnis – Vollmacht nicht erlaubt',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const repName = `${relation.representative.firstName} ${relation.representative.lastName}`;
|
||||||
|
|
||||||
let auth;
|
let auth;
|
||||||
if (grant) {
|
if (grant) {
|
||||||
|
|
@ -991,10 +1006,9 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
|
||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Ändern der Vollmacht:', error);
|
console.error('Fehler beim Ändern der Vollmacht:', error);
|
||||||
res.status(400).json({
|
// Generische Fehlermeldung – Prisma-Errors enthalten Pfad/Schema und
|
||||||
success: false,
|
// sollten nicht an Endkunden geleakt werden.
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Ändern',
|
res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
@ -57,10 +58,15 @@ const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
|
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
|
||||||
// `trust proxy = 1` = dem ersten Hop X-Forwarded-For vertrauen (damit req.ip
|
// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
|
||||||
// die echte Client-IP ist). Wichtig für express-rate-limit, sonst teilen sich
|
// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
|
||||||
// alle Requests dieselbe Proxy-IP und das Rate-Limit ist unwirksam.
|
// auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen,
|
||||||
app.set('trust proxy', 1);
|
// während gleichzeitig der lokale Reverse-Proxy die echte Client-IP liefern darf.
|
||||||
|
//
|
||||||
|
// WICHTIG für Production: Backend nur auf 127.0.0.1 lauschen lassen
|
||||||
|
// (LISTEN_ADDR=127.0.0.1) – sonst kann ein direkter Connect von außen
|
||||||
|
// trotzdem als loopback gelten, falls das Routing das so durchstellt.
|
||||||
|
app.set('trust proxy', 'loopback');
|
||||||
|
|
||||||
// ==================== SECURITY MIDDLEWARE ====================
|
// ==================== SECURITY MIDDLEWARE ====================
|
||||||
|
|
||||||
|
|
@ -95,8 +101,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);
|
||||||
|
|
@ -169,8 +179,13 @@ app.use((err: Error & { status?: number; type?: string }, req: express.Request,
|
||||||
res.status(status).json({ success: false, error: message });
|
res.status(status).json({ success: false, error: message });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
|
||||||
console.log(`Server läuft auf Port ${PORT}`);
|
// Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar.
|
||||||
|
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
||||||
|
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
||||||
|
|
||||||
|
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||||
|
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||||
startBirthdayScheduler();
|
startBirthdayScheduler();
|
||||||
startContractStatusScheduler();
|
startContractStatusScheduler();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,77 @@ 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 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):**
|
||||||
|
- 🚨 **`GET /api/customers` leakte als Portal-User die komplette
|
||||||
|
Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der
|
||||||
|
Single-Endpoint war Stage 4 mit `canAccessCustomer` gefixt, der List-
|
||||||
|
Endpoint nicht. Jetzt: Portal-User bekommt nur eigene + vertretene
|
||||||
|
Kunden (Filter im Controller).
|
||||||
|
- 🚨 **Rate-Limit-Bypass via `X-Forwarded-For`**: 12+ Login-Versuche
|
||||||
|
mit rotierenden XFF-Werten gingen alle durch ohne 429. `trust proxy = 1`
|
||||||
|
hat naiv jedem XFF-Wert vertraut. Jetzt: `trust proxy = 'loopback'` –
|
||||||
|
XFF wird nur akzeptiert wenn die Connection von 127.0.0.1 / ::1 kommt
|
||||||
|
(= lokaler Reverse-Proxy). Plus: `LISTEN_ADDR=127.0.0.1` in Production-
|
||||||
|
Default, damit das Backend nicht direkt von außen ansprechbar ist.
|
||||||
|
- **Self-Grant + Existence-Disclosure in `toggleMyAuthorization`**:
|
||||||
|
- Portal-User konnte sich selbst Vollmacht erteilen (1→1) und
|
||||||
|
Datensätze für beliebige `representativeId`s anlegen (auch nicht-
|
||||||
|
existierende, scheiterte erst auf DB-Constraint mit Prisma-Stack-Leak).
|
||||||
|
- 404 vs 403 erlaubte Existence-Probing der gesamten customer-ID-Range.
|
||||||
|
- Fix: Self-Grant 400er. Existenz + aktives `CustomerRepresentative`-
|
||||||
|
Verhältnis in einem Query, beide Fehlfälle identisch 403.
|
||||||
|
- **Prisma-Error-Leak generisch in `toggleMyAuthorization`**: keine
|
||||||
|
Prisma-Stacks mehr im Response.
|
||||||
|
- Live-verifiziert: Customer-Liste 3 statt 3000 (jetzt nur erlaubte),
|
||||||
|
Self-Grant 400, Existence-Disclosure dicht (alle 403 uniform), Auth
|
||||||
|
auf `/api/customers/:id` 200/403 (kein 404-Leak).
|
||||||
|
|
||||||
|
**Geprüft + sauber (Runde 6):**
|
||||||
|
- Prototype Pollution beim Login → kein Effekt
|
||||||
|
- HTTP-Method-Override via Header → ignoriert
|
||||||
|
- Path-Traversal in Backup-Name → durch Regex blockiert
|
||||||
|
- Developer-Routes existieren nicht (404)
|
||||||
|
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
|
||||||
|
- Self-grant Vollmacht via `customers/X/representatives` → 403 (perm)
|
||||||
|
- `/api/customers/:id` GET: 200 für eigene, 403 sonst (kein 404-Leak)
|
||||||
|
|
||||||
|
**Offen für v1.1:**
|
||||||
|
- `/api/contracts/:id` GET liefert 404 für nicht-existente IDs (Existence-
|
||||||
|
Probing). Da contractIds aber nicht direkt mit personenbezogenen Daten
|
||||||
|
korrelieren, niedrig-Prio. Vereinheitlichung auf 403 wäre sauberer.
|
||||||
|
- Prisma-Error-Leaks in anderen Admin-Endpoints (z.B. `addInvoice` bei
|
||||||
|
Validation-Fehler) – Defense-in-Depth-Kandidat.
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue