Compare commits
No commits in common. "33adbcd1a5046e6739cef27abb2726929f088d77" and "12b9abe9791bb7140e42d2dcb08d76fe7d02f037" have entirely different histories.
33adbcd1a5
...
12b9abe979
|
|
@ -7,7 +7,7 @@ import { ApiResponse } from '../types/index.js';
|
|||
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
|
||||
import { assertAllowedHost } from '../utils/ssrfGuard.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
|
@ -119,15 +119,13 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
|||
domain: req.body.domain,
|
||||
} : undefined;
|
||||
|
||||
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
|
||||
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen – ohne dass
|
||||
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
|
||||
// SSRF-Guard: testData.apiUrl-Hostname prüfen
|
||||
if (testData?.apiUrl) {
|
||||
try {
|
||||
const url = new URL(testData.apiUrl);
|
||||
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
||||
assertAllowedHost(url.hostname, 'apiUrl-Host');
|
||||
} catch (err) {
|
||||
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
||||
if (err instanceof Error && err.message.includes('geblockte')) {
|
||||
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
|
@ -231,17 +229,12 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
|||
return;
|
||||
}
|
||||
|
||||
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
|
||||
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
||||
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
||||
// ein zweiter DNS-Lookup keine andere IP unterschieben.
|
||||
let smtpResolved: { ip: string; servername: string };
|
||||
let imapResolved: { ip: string; servername: string };
|
||||
// SSRF-Guard: Wenn der Host vom Body kommt, blockieren wir Cloud-Metadata
|
||||
// und Reserved-Ranges. Loopback/Private-Ranges bleiben erlaubt für
|
||||
// legitime Plesk/Postfix-Setups.
|
||||
try {
|
||||
[smtpResolved, imapResolved] = await Promise.all([
|
||||
safeResolveHost(smtpServer, 'SMTP-Server'),
|
||||
safeResolveHost(imapServer, 'IMAP-Server'),
|
||||
]);
|
||||
assertAllowedHost(smtpServer, 'SMTP-Server');
|
||||
assertAllowedHost(imapServer, 'IMAP-Server');
|
||||
} catch (err) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
|
@ -252,24 +245,22 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
|||
|
||||
// IMAP testen
|
||||
const imapCredentials: ImapCredentials = {
|
||||
host: imapResolved.ip,
|
||||
host: imapServer,
|
||||
port: imapPort,
|
||||
user: emailAddress,
|
||||
password,
|
||||
encryption: imapEncryption,
|
||||
allowSelfSignedCerts,
|
||||
servername: imapResolved.servername,
|
||||
};
|
||||
|
||||
// SMTP testen
|
||||
const smtpCredentials: SmtpCredentials = {
|
||||
host: smtpResolved.ip,
|
||||
host: smtpServer,
|
||||
port: smtpPort,
|
||||
user: emailAddress,
|
||||
password,
|
||||
encryption: smtpEncryption,
|
||||
allowSelfSignedCerts,
|
||||
servername: smtpResolved.servername,
|
||||
};
|
||||
|
||||
let imapResult: { success: boolean; error?: string } = { success: false };
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import { Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import { findUploadOwner } from '../services/fileDownload.service.js';
|
||||
import { canAccessCustomer, canAccessContract } from '../utils/accessControl.js';
|
||||
|
||||
/**
|
||||
* Authentifizierter Download-Endpoint mit Per-File-Ownership-Check.
|
||||
* Ersetzt das ungeschützte `express.static('/api/uploads')`.
|
||||
*
|
||||
* Aufruf: GET /api/files/download?path=/uploads/<subDir>/<filename>
|
||||
*
|
||||
* Schritte:
|
||||
* 1. Pfad-Format prüfen (muss mit /uploads/ beginnen, kein Traversal)
|
||||
* 2. Owner via DB-Lookup ermitteln (welcher Customer/Contract gehört dazu?)
|
||||
* 3. canAccessCustomer / canAccessContract / Permission-Check
|
||||
* 4. Datei senden (mit korrektem Content-Type)
|
||||
*
|
||||
* Sicherheitsgewinn ggü. dem alten static-Handler: ein eingeloggter
|
||||
* Portal-Kunde kann jetzt nur seine eigenen Files (oder die seiner
|
||||
* vertretenen Kunden mit Vollmacht) herunterladen – nicht mehr beliebige
|
||||
* Pfade von fremden Kunden, selbst wenn er die Filenames irgendwo
|
||||
* mitgeschnitten hätte.
|
||||
*/
|
||||
export async function downloadFile(req: AuthRequest, res: Response): Promise<void> {
|
||||
const requested = typeof req.query.path === 'string' ? req.query.path : '';
|
||||
if (!requested) {
|
||||
res.status(400).json({ success: false, error: 'path-Parameter fehlt' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Format-Validierung (Traversal-Schutz)
|
||||
if (!requested.startsWith('/uploads/') || requested.includes('..') || requested.includes('\0')) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Owner ermitteln
|
||||
const owner = await findUploadOwner(requested);
|
||||
if (!owner) {
|
||||
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Access-Check je nach Owner-Typ
|
||||
if (owner.kind === 'customer') {
|
||||
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
|
||||
} else if (owner.kind === 'contract') {
|
||||
if (!(await canAccessContract(req, res, owner.contractId))) return;
|
||||
} else if (owner.kind === 'admin') {
|
||||
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
|
||||
const perms = req.user?.permissions || [];
|
||||
if (!perms.includes('settings:read') && !perms.includes('settings:update')) {
|
||||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
} else if (owner.kind === 'gdpr-admin') {
|
||||
const perms = req.user?.permissions || [];
|
||||
if (!perms.includes('gdpr:admin')) {
|
||||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Datei vom Disk lesen
|
||||
// requested startet mit /uploads/, wir mappen das auf process.cwd()/uploads/...
|
||||
const relative = requested.substring('/uploads/'.length);
|
||||
const absolute = path.join(process.cwd(), 'uploads', relative);
|
||||
// Letzter Pfad-Sicherheitscheck: absolute Path muss noch unter uploads/ liegen.
|
||||
const uploadsRoot = path.join(process.cwd(), 'uploads') + path.sep;
|
||||
if (!absolute.startsWith(uploadsRoot)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(absolute)) {
|
||||
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Content-Type aus Extension bestimmen (konservativ – Express macht das eh)
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.sendFile(absolute);
|
||||
}
|
||||
|
|
@ -34,7 +34,6 @@ import emailLogRoutes from './routes/emailLog.routes.js';
|
|||
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||
import birthdayRoutes from './routes/birthday.routes.js';
|
||||
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||
import { downloadFile } from './controllers/fileDownload.controller.js';
|
||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||
|
|
@ -102,28 +101,12 @@ app.use(express.json({ limit: '5mb' }));
|
|||
app.use(auditContextMiddleware);
|
||||
app.use(auditMiddleware);
|
||||
|
||||
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
|
||||
// `/api/uploads/*` express.static).
|
||||
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
|
||||
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
|
||||
// und prüft canAccessCustomer/canAccessContract – damit kann ein Portal-Kunde
|
||||
// nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt.
|
||||
//
|
||||
// Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden
|
||||
// Request über denselben Owner-Check (kein freier static-Handler mehr).
|
||||
|
||||
// Authentifizierter Datei-Download mit Per-File-Ownership-Check.
|
||||
// Akzeptiert Pfade wie /uploads/bank-cards/<filename> – egal ob als
|
||||
// Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler,
|
||||
// der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf.
|
||||
app.get('/api/files/download', authenticate as any, downloadFile as any);
|
||||
// Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher
|
||||
// für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler.
|
||||
app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
|
||||
// Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen
|
||||
req.query.path = req.originalUrl.replace(/^\/api/, '').split('?')[0];
|
||||
return (downloadFile as any)(req, res, next);
|
||||
});
|
||||
// Statische Dateien für Uploads – NUR für authentifizierte User.
|
||||
// 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)
|
||||
app.use('/api/public/consent', consentPublicRoutes);
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
/**
|
||||
* Pfad → Resource → Owner Mapping für `/api/files/download`.
|
||||
*
|
||||
* Jeder Upload-Subdirectory ist mit genau einem Prisma-Model + Path-Field
|
||||
* verknüpft. Wir suchen den Record, der diesen Path referenziert, und
|
||||
* leiten daraus den zuständigen Customer/Contract ab. canAccessCustomer /
|
||||
* canAccessContract entscheidet danach über Zugriff.
|
||||
*
|
||||
* Pfade werden 1:1 mit dem in der DB gespeicherten Wert verglichen
|
||||
* (z.B. `/uploads/bank-cards/12345.pdf`). Damit ist Path-Traversal
|
||||
* automatisch ausgeschlossen – ein konstruierter Pfad findet keinen Record.
|
||||
*/
|
||||
import prisma from '../lib/prisma.js';
|
||||
|
||||
export type FileOwner =
|
||||
| { kind: 'customer'; customerId: number }
|
||||
| { kind: 'contract'; contractId: number }
|
||||
| { kind: 'admin' }
|
||||
| { kind: 'gdpr-admin' };
|
||||
|
||||
export async function findUploadOwner(uploadPath: string): Promise<FileOwner | null> {
|
||||
// Format-Check: muss mit /uploads/<subDir>/<filename> beginnen, kein Traversal.
|
||||
if (!uploadPath.startsWith('/uploads/')) return null;
|
||||
if (uploadPath.includes('..') || uploadPath.includes('\0')) return null;
|
||||
|
||||
const parts = uploadPath.split('/');
|
||||
// ['', 'uploads', '<subDir>', '<filename...>']
|
||||
if (parts.length < 4) return null;
|
||||
const subDir = parts[2];
|
||||
|
||||
switch (subDir) {
|
||||
case 'bank-cards': {
|
||||
const r = await prisma.bankCard.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'documents': {
|
||||
const r = await prisma.identityDocument.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'business-registrations': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { businessRegistrationPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'commercial-registers': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { commercialRegisterPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'privacy-policies': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { privacyPolicyPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'authorizations': {
|
||||
const r = await prisma.representativeAuthorization.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'contract-documents': {
|
||||
const r = await prisma.contractDocument.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { contractId: true },
|
||||
});
|
||||
return r ? { kind: 'contract', contractId: r.contractId } : null;
|
||||
}
|
||||
|
||||
case 'invoices': {
|
||||
const r = await prisma.invoice.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { contractId: true },
|
||||
});
|
||||
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
|
||||
}
|
||||
|
||||
case 'cancellation-letters':
|
||||
case 'cancellation-confirmations':
|
||||
case 'cancellation-letters-options':
|
||||
case 'cancellation-confirmations-options': {
|
||||
const fieldMap: Record<string, 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath'> = {
|
||||
'cancellation-letters': 'cancellationLetterPath',
|
||||
'cancellation-confirmations': 'cancellationConfirmationPath',
|
||||
'cancellation-letters-options': 'cancellationLetterOptionsPath',
|
||||
'cancellation-confirmations-options': 'cancellationConfirmationOptionsPath',
|
||||
};
|
||||
const field = fieldMap[subDir];
|
||||
const r = await prisma.contract.findFirst({
|
||||
where: { [field]: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'contract', contractId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'pdf-templates': {
|
||||
// Admin-only Resource: Vorlagen gehören keinem Customer.
|
||||
const r = await prisma.pdfTemplate.findFirst({
|
||||
where: { templatePath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'admin' } : null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,9 +14,6 @@ export interface ImapCredentials {
|
|||
password: string;
|
||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
||||
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
||||
servername?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -32,12 +29,6 @@ function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown>
|
|||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||
const options: Record<string, unknown> = { rejectUnauthorized };
|
||||
|
||||
// DNS-Rebinding-Schutz: wenn host eine IP ist und der ursprüngliche
|
||||
// Hostname als servername mitgeliefert wird, nutze ihn für SNI/Cert.
|
||||
if (credentials.servername) {
|
||||
options.servername = credentials.servername;
|
||||
}
|
||||
|
||||
if (credentials.allowSelfSignedCerts) {
|
||||
options.minVersion = 'TLSv1';
|
||||
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
||||
|
|
|
|||
|
|
@ -15,10 +15,6 @@ export interface SmtpCredentials {
|
|||
password: string;
|
||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
||||
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
||||
// Damit kann ein zweiter DNS-Lookup nicht plötzlich auf eine interne IP zeigen.
|
||||
servername?: string;
|
||||
}
|
||||
|
||||
// Anhang-Interface
|
||||
|
|
@ -98,7 +94,7 @@ export async function sendEmail(
|
|||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||
ignoreTLS?: boolean;
|
||||
requireTLS?: boolean;
|
||||
connectionTimeout: number;
|
||||
|
|
@ -120,11 +116,6 @@ export async function sendEmail(
|
|||
// TLS-Optionen nur wenn nicht NONE
|
||||
if (encryption !== 'NONE') {
|
||||
transportOptions.tls = { rejectUnauthorized };
|
||||
// DNS-Rebinding-Schutz: wenn host eine IP ist, der ursprüngliche
|
||||
// Hostname für SNI/Cert-Validation explizit setzen.
|
||||
if (credentials.servername) {
|
||||
transportOptions.tls.servername = credentials.servername;
|
||||
}
|
||||
if (credentials.allowSelfSignedCerts) {
|
||||
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
||||
transportOptions.tls.minVersion = 'TLSv1';
|
||||
|
|
@ -312,7 +303,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||
ignoreTLS?: boolean;
|
||||
connectionTimeout: number;
|
||||
greetingTimeout: number;
|
||||
|
|
@ -330,9 +321,6 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||
|
||||
if (encryption !== 'NONE') {
|
||||
transportOptions.tls = { rejectUnauthorized };
|
||||
if (credentials.servername) {
|
||||
transportOptions.tls.servername = credentials.servername;
|
||||
}
|
||||
} else {
|
||||
transportOptions.ignoreTLS = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,53 +55,3 @@ export function assertAllowedHost(host: string | null | undefined, label = 'Host
|
|||
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
|
||||
}
|
||||
}
|
||||
|
||||
import { promises as dns } from 'dns';
|
||||
import net from 'net';
|
||||
|
||||
/**
|
||||
* DNS-Rebinding-Schutz: löst den Hostname zu allen IPs auf und prüft jede
|
||||
* gegen die Block-Liste. Wirft wenn IRGENDEINE IP geblockt ist.
|
||||
*
|
||||
* Das Resultat enthält die erste (geprüfte) IP plus den Original-Hostname
|
||||
* als `servername` für TLS-SNI / Cert-Validation. Der Caller muss die
|
||||
* Connection mit `host=ip` und `tls.servername=hostname` aufbauen, damit
|
||||
* ein zweiter DNS-Lookup keine andere (geblockte) IP liefern kann.
|
||||
*
|
||||
* Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft.
|
||||
*/
|
||||
export async function safeResolveHost(host: string | null | undefined, label = 'Host'): Promise<{ ip: string; servername: string }> {
|
||||
if (!host || !host.trim()) {
|
||||
throw new Error(`${label} fehlt`);
|
||||
}
|
||||
const trimmed = host.trim();
|
||||
|
||||
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
||||
if (net.isIP(trimmed)) {
|
||||
assertAllowedHost(trimmed, label);
|
||||
return { ip: trimmed, servername: trimmed };
|
||||
}
|
||||
|
||||
// Hostname → resolve to IPv4 + IPv6
|
||||
let ips: string[] = [];
|
||||
try {
|
||||
const v4 = await dns.resolve4(trimmed).catch(() => [] as string[]);
|
||||
const v6 = await dns.resolve6(trimmed).catch(() => [] as string[]);
|
||||
ips = [...v4, ...v6];
|
||||
} catch {
|
||||
throw new Error(`${label}: DNS-Auflösung fehlgeschlagen für ${trimmed}`);
|
||||
}
|
||||
|
||||
if (ips.length === 0) {
|
||||
throw new Error(`${label}: keine IP-Adresse für ${trimmed} gefunden`);
|
||||
}
|
||||
|
||||
// Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung.
|
||||
for (const ip of ips) {
|
||||
if (isBlockedSsrfHost(ip)) {
|
||||
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
|
||||
}
|
||||
}
|
||||
|
||||
return { ip: ips[0], servername: trimmed };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
## 🔜 Offen
|
||||
|
||||
### Manuelle Tests (vor Release durchklicken)
|
||||
Checklisten für Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**.
|
||||
Checklisten für Security + Email-Log-System stehen in **[docs/TESTING.md](../docs/TESTING.md)**.
|
||||
Einmal komplett durchlaufen vor v1.0.0-Release.
|
||||
|
||||
### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
|
||||
|
|
@ -116,22 +116,160 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
|
||||
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
|
||||
|
||||
- [x] **🛡️ Security-Hardening vor Production-Deployment (8 Runden)**
|
||||
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
|
||||
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
|
||||
- Erste 2 Runden zusätzlich ausführlich in
|
||||
[SECURITY-REVIEW.md](./SECURITY-REVIEW.md)
|
||||
- Highlights:
|
||||
- Runde 1–3: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass
|
||||
Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
|
||||
- Runde 4: 9 Live-IDORs (customer.\*/gdpr.\*) + Error-Handler
|
||||
- Runde 5: `/api/uploads`-Auth (DSGVO-GAU), Login-Timing,
|
||||
Privacy-Policy-XSS
|
||||
- Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass,
|
||||
Self-Grant + Existence-Disclosure
|
||||
- Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
|
||||
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
|
||||
- Deployment-Checkliste komplett (in HARDENING.md)
|
||||
- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (3 Runden)**
|
||||
- Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)**
|
||||
- **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**
|
||||
- CORS offen → `CORS_ORIGINS` explizit
|
||||
- Helmet + Security-Headers
|
||||
- JWT-Fallback-Secret entfernt (Fail-Fast beim Start)
|
||||
- IDOR bei 7 Contract-Endpoints
|
||||
- XSS via Email-Body (DOMPurify)
|
||||
- Customer-API Data Exposure (Passwort-Hashes)
|
||||
- Portal-JWT-Invalidation nach Passwort-Reset
|
||||
- Body-Size-Limit 5 MB
|
||||
- **Runde 2 – Deep-Dive mit parallelen Audit-Agents, 5 weitere kritische + 2 wichtige:**
|
||||
- Zip-Slip im Backup-Upload (Arbitrary File Write!)
|
||||
- Mass Assignment bei Customer/User (Privilege Escalation via `roleIds`!)
|
||||
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
|
||||
- Path-Traversal bei Backup-Name und GDPR-Proof-Download
|
||||
- **Runde 3 – Tiefer Dive (8 weitere Hardenings):**
|
||||
- JWT algorithm confusion: `jwt.verify` auf `algorithms: ['HS256']` festgenagelt
|
||||
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy (sonst unwirksam)
|
||||
- IDOR Invoice (alte `/api/energy-details/:ecdId/invoices`): jetzt `canAccessEnergyContractDetails` → Contract → customerId
|
||||
- IDOR PDF-Template-Generator (`:id/generate/:contractId`): jetzt `canAccessContract`
|
||||
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
|
||||
- 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)
|
||||
- bcrypt cost 10 → 12 (OWASP 2026)
|
||||
- **Runde 7 – Letzter Schliff (SSRF + Logout):**
|
||||
- **SSRF-Schutz** in `test-connection` und `test-mail-access`: ein
|
||||
Admin-User konnte über die Plesk-API-URL bzw. SMTP/IMAP-Server-Felder
|
||||
Connections zu beliebigen IPs auslösen (Cloud-Metadata-Endpoints,
|
||||
Link-Local, AWS/GCP-Metadata-Hosts). Internal-Port-Scanning via
|
||||
Timing-Differenzen war messbar (22/80/3306/5432/6379 unterschiedlich).
|
||||
Fix: neuer Helper `utils/ssrfGuard.ts` blockiert vor jeder ausgehenden
|
||||
Verbindung 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges,
|
||||
AWS-IPv6-Metadata, IPv6-Link-Local und bekannte Cloud-Metadata-
|
||||
Hostnames (metadata.google.internal etc.). Loopback (127.0.0.0/8)
|
||||
bleibt erlaubt für legitime Plesk/Postfix-Setups.
|
||||
- **Logout-Endpoint** `POST /api/auth/logout`: setzt
|
||||
`tokenInvalidatedAt` / `portalTokenInvalidatedAt` auf jetzt. Auth-
|
||||
Middleware prüft das Feld und lehnt Tokens mit `iat` davor ab.
|
||||
JWTs sind stateless – ohne diesen Mechanismus bleibt ein
|
||||
„abgemeldeter" Token bis zum natürlichen Expiry (7d) gültig.
|
||||
- Live-verifiziert: 169.254.169.254/metadata.google.internal/0.0.0.0
|
||||
werden mit 400 abgelehnt; 127.0.0.1 weiter erlaubt; Logout
|
||||
invalidiert den Token sofort (HTTP 401 „Sitzung ungültig").
|
||||
|
||||
**Geprüft + sauber (Runde 7):**
|
||||
- Public Consent (random Hash → 404, kein Brute-Force durch 122-bit-UUID)
|
||||
- Magic-Bytes-Bypass beim Upload (HTML als image/png) → blockiert
|
||||
- PDF-Generation mit injizierten manualValues → kein XSS-Vektor (PDFs sind keine Web-Renderer)
|
||||
- Audit-Logs für Portal-User: 403
|
||||
- Email-Config-Update als Portal: 403
|
||||
- Backup-Endpoints als Portal: 403
|
||||
- Query-Filter-Override (?customerId=X) → vom Portal-Filter ignoriert
|
||||
|
||||
**Bewusst NICHT gefixt (zu invasiv für v1.0):**
|
||||
- Vollständige DNS-Resolution beim SSRF-Guard (gegen DNS-Rebinding) –
|
||||
kann legitimes CDN/Caching brechen, v1.1-Item.
|
||||
- Per-File-Ownership-Check bei `/api/uploads` (siehe Runde 5).
|
||||
|
||||
- **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:**
|
||||
- `getCustomer` + `getAddresses`/`getBankCards`/`getDocuments`/`getMeters`/`getRepresentatives`/`getPortalSettings` hatten NUR Daten-Sanitizer aber KEINEN `canAccessCustomer`-Check
|
||||
- `gdpr.getCustomerConsents` + `getAuthorizations` + `checkConsentStatus` ebenso ungeschützt
|
||||
- Portal-Kunde konnte live per `GET /api/customers/<fremde-id>` kompletten Fremdkunden-Datensatz auslesen → jetzt 403
|
||||
- Error-Handler: `err.status` wird jetzt respektiert (413/400 statt pauschalem 500)
|
||||
|
||||
**Live-verifiziert als Portal-Kunde gegen fremden Test-Kunden #4:**
|
||||
|
||||
| Endpoint | Vorher | Nachher |
|
||||
| -------------------------------------------- | ------------------------------- | ---------------------------- |
|
||||
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
|
||||
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
|
||||
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
|
||||
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
|
||||
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
|
||||
|
||||
- Deployment-Checkliste komplett
|
||||
|
||||
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
|
||||
- **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link)
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
# 🛡️ Security-Hardening – die ganze Geschichte
|
||||
|
||||
Dokumentiert die acht Hardening-Runden, die OpenCRM zwischen erster
|
||||
Code-Review und öffentlichem Deployment durchlaufen hat.
|
||||
|
||||
Format pro Runde: **Was war kaputt** → **Wie es gefixt wurde** → wo möglich
|
||||
**Live-Test-Resultate**.
|
||||
|
||||
> Die ersten beiden Runden gibt es zusätzlich als ausführlicheren Review in
|
||||
> [SECURITY-REVIEW.md](./SECURITY-REVIEW.md).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Live-verifizierte Tests im Überblick
|
||||
|
||||
Die wichtigsten Schwachstellen wurden mit echten HTTP-Requests gegen den Dev-Server
|
||||
durchgespielt – statisches Code-Review fand ca. 70 % der Findings, die letzten 30 %
|
||||
brauchten Live-Tests.
|
||||
|
||||
### Runde 4 – IDOR an Customer-Sub-Resourcen (Live als Portal-Kunde)
|
||||
|
||||
| Endpoint | Vorher | Nachher |
|
||||
| -------------------------------------------- | ------------------------------- | ---------------------------- |
|
||||
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
|
||||
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
|
||||
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
|
||||
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
|
||||
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
|
||||
|
||||
### Runde 5 – DSGVO-GAU + Timing-Side-Channel
|
||||
|
||||
| Test | Vorher | Nachher |
|
||||
| ------------------------------------------------- | --------------------------------------- | ---------------------------------- |
|
||||
| `/api/uploads/cancellation-confirmations/*.pdf` | 🚨 **HTTP 200 mit echtem Kunden-PDF** | ✅ 401 ohne Token |
|
||||
| `/api/uploads/...?token=<jwt>` | n/a | ✅ 200 |
|
||||
| Login `admin@admin.com` (falsches Passwort) | 110 ms | 423 ms |
|
||||
| Login `not-existent@x.de` | 10 ms (verräterisch) | 422 ms (matcht admin) |
|
||||
| Portal-Lieferbestätigung-Upload auf fremden Vertrag | (per-Permission abgewehrt) | ✅ 403 |
|
||||
|
||||
### Runde 6 – Customer-Liste-Leak + XFF-Bypass
|
||||
|
||||
| Test | Vorher | Nachher |
|
||||
| --------------------------------------------- | --------------------------------------- | ---------------------------------------- |
|
||||
| `GET /api/customers` als Portal | 🚨 **alle Kunden mit Namen/E-Mail** | ✅ nur eigene + vertretene |
|
||||
| 12× Login mit rotierendem `X-Forwarded-For` | 🚨 alle 401, kein 429 | ✅ XFF nur von Loopback akzeptiert |
|
||||
| Self-Grant (`representativeId == customerId`) | 🚨 DB-Eintrag erstellt | ✅ 400 |
|
||||
| Authorization für non-existent Customer 9999 | 🚨 Prisma-Stack mit Pfaden geleakt | ✅ 403 generisch |
|
||||
| Customer-Existence via 404-vs-403 | 🟡 enumerierbar | ✅ alle 403 uniform |
|
||||
| Listen-Adresse (Production) | `0.0.0.0` (extern erreichbar) | `127.0.0.1` (nur via Reverse-Proxy) |
|
||||
|
||||
### Runde 7 – SSRF + Logout
|
||||
|
||||
| Test | Vorher | Nachher |
|
||||
| ----------------------------------------------------------- | --------------------- | ---------------------------------------- |
|
||||
| `test-connection` mit `apiUrl=http://169.254.169.254` | 8 s Timeout (SSRF) | ✅ 400 „geblockte Adresse" |
|
||||
| `test-mail-access` mit `smtpServer=metadata.google.internal`| Connection-Versuch | ✅ 400 |
|
||||
| `test-mail-access` mit `0.0.0.0` | Connection-Versuch | ✅ 400 |
|
||||
| `test-mail-access` mit `127.0.0.1` (Plesk-Fall) | OK | ✅ OK (weiter erlaubt) |
|
||||
| `POST /api/auth/logout` | 404 (Endpoint fehlte) | ✅ 200 |
|
||||
| `GET /me` nach Logout | weiter 200 (bis 7 d) | ✅ 401 „Sitzung ungültig" |
|
||||
|
||||
### Runde 8 – DNS-Rebinding + Per-File-Ownership
|
||||
|
||||
| Test | Resultat |
|
||||
| ----------------------------------------------------- | --------------------------------------------- |
|
||||
| Admin lädt eigene Datei | ✅ HTTP 200, PDF |
|
||||
| Portal lädt eigene Contract-Datei | ✅ HTTP 200, PDF |
|
||||
| Portal lädt random Pfad ohne DB-Resource | ✅ HTTP 404 |
|
||||
| Path-Traversal `..` im Pfad | ✅ HTTP 400 |
|
||||
| URL-encoded Traversal `%2F..%2F` | ✅ HTTP 400 |
|
||||
| Ohne Token | ✅ HTTP 401 |
|
||||
| Backwards-Compat `/api/uploads/<path>` | ✅ HTTP 200 (intern derselbe Owner-Check) |
|
||||
| Legitimer Hostname (gmail.com) | ✅ DNS-Resolve OK, normaler SMTP-Auth-Fail |
|
||||
| Hostname mit interner Target-IP | ✅ HTTP 400 geblockt |
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Runde-für-Runde
|
||||
|
||||
### Runde 1 – Erste kritische Findings (statisches Review)
|
||||
|
||||
- CORS komplett offen → `CORS_ORIGINS` explizit
|
||||
- Keine Security-Headers → Helmet aktiviert (HSTS, X-Frame-Options, nosniff …)
|
||||
- JWT-Fallback-Secret entfernt → Fail-Fast beim Start (≥ 32 Zeichen JWT_SECRET, 64-Hex ENCRYPTION_KEY)
|
||||
- IDOR bei 7 Contract-Endpoints (`canAccessContract`)
|
||||
- XSS via Email-Body → DOMPurify mit strikter Config
|
||||
- Customer-API: Passwort-Hashes in API-Responses → Sanitizer
|
||||
- Portal-JWT-Invalidation nach Passwort-Reset (`portalTokenInvalidatedAt`)
|
||||
- Body-Size-Limit 5 MB
|
||||
|
||||
### Runde 2 – Deep-Dive (parallele Audit-Agents)
|
||||
|
||||
- **Zip-Slip im Backup-Upload** (Arbitrary File Write) → Pfad-Validation
|
||||
- **Mass Assignment bei Customer/User** (Privilege Escalation via `roleIds`!) → Whitelist-Picker
|
||||
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
|
||||
- Path-Traversal bei Backup-Name und GDPR-Proof-Download → Regex/Safelist
|
||||
|
||||
### Runde 3 – Tiefer Dive (8 weitere Hardenings)
|
||||
|
||||
- JWT algorithm confusion: `jwt.verify(..., { algorithms: ['HS256'] })`
|
||||
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy
|
||||
- IDOR Invoice (`/api/energy-details/:ecdId/invoices`) → `canAccessEnergyContractDetails`
|
||||
- IDOR PDF-Template-Generator → `canAccessContract`
|
||||
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
|
||||
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
|
||||
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail`
|
||||
- bcrypt cost 10 → 12 (OWASP 2026)
|
||||
|
||||
### Runde 4 – Live-Tests gegen Dev-Server (Tabelle oben)
|
||||
|
||||
`getCustomer`, alle Customer-Sub-Resources (addresses/bank-cards/…) und die
|
||||
GDPR-Endpoints hatten nur Daten-Sanitizer, aber keinen `canAccessCustomer`-Check.
|
||||
Portal-Kunde konnte live `GET /api/customers/<fremde-id>` machen → **9 IDORs**.
|
||||
|
||||
Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500).
|
||||
|
||||
### Runde 5 – Hack-Das-Ding-Audit
|
||||
|
||||
- 🚨 **`/api/uploads/*` ohne Auth** (DSGVO-GAU) → `authenticate`-Middleware,
|
||||
Frontend-Helper `fileUrl(path)` hängt Token an, 24 URLs migriert.
|
||||
- **Login-Timing-Side-Channel**: 110 ms vs 10 ms → Dummy-bcrypt-compare
|
||||
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
|
||||
- **XSS via Privacy Policy / Imprint** in 4 Frontend-Seiten → DOMPurify.
|
||||
- IDOR-Härtung an 5 weiteren Upload/Delete/Email-Save-Stellen
|
||||
(`canAccessContract`).
|
||||
|
||||
### Runde 6 – Tiefer Live-Pentest (Tabelle oben)
|
||||
|
||||
- 🚨 **`GET /api/customers` Customer-Liste-Leak** → Portal-Filter
|
||||
- 🚨 **Rate-Limit-Bypass via X-Forwarded-For** → `trust proxy = 'loopback'`
|
||||
+ `LISTEN_ADDR=127.0.0.1` in Production
|
||||
- Self-Grant + Existence-Disclosure in `toggleMyAuthorization` → Self-Grant
|
||||
400, Existenz + aktive `CustomerRepresentative`-Beziehung in einem Query,
|
||||
beide Fehlfälle uniform 403.
|
||||
- Prisma-Error-Leaks generisch ersetzt.
|
||||
|
||||
### Runde 7 – Letzter Schliff
|
||||
|
||||
- **SSRF-Schutz** in `test-connection` und `test-mail-access` →
|
||||
`utils/ssrfGuard.ts` blockiert 169.254.0.0/16, 0.0.0.0/8,
|
||||
Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und
|
||||
Cloud-Metadata-Hostnames. Loopback bleibt erlaubt für Plesk/Postfix.
|
||||
- **Logout-Endpoint** `POST /api/auth/logout` setzt `tokenInvalidatedAt`
|
||||
/ `portalTokenInvalidatedAt` auf jetzt.
|
||||
|
||||
### Runde 8 – Loose Ends
|
||||
|
||||
- **DNS-Rebinding-Schutz**: `safeResolveHost()` löst Hostnames vor Connect
|
||||
zu IPs auf, prüft jede gegen die Block-Liste, gibt `{ ip, servername }`
|
||||
zurück. Connection läuft gegen IP, der Hostname als TLS-SNI – ein
|
||||
zweiter DNS-Lookup kann keine geblockte IP unterschieben.
|
||||
- **Per-File-Ownership-Check**: `app.use('/api/uploads', authenticate,
|
||||
express.static)` ersetzt durch `GET /api/files/download?path=...` mit
|
||||
DB-Lookup (`fileDownload.service.ts`). 12 subDir-Mappings → Customer
|
||||
oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
|
||||
Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Geprüft + sauber (kein Bug, aber explizit getestet)
|
||||
|
||||
- Prototype Pollution beim Login (Body mit `__proto__` → kein Effekt)
|
||||
- HTTP-Method-Override-Header (X-HTTP-Method-Override: DELETE → ignoriert)
|
||||
- Path-Traversal in Backup-Name (Regex blockiert)
|
||||
- Developer-Routes existieren nicht (`/api/developer/*` → 404)
|
||||
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
|
||||
- Self-grant Vollmacht via `customers/X/representatives` → 403
|
||||
- `/api/customers/:id` GET liefert 403 für fremde, kein 404-Existence-Leak
|
||||
- Public Consent Endpoint: 122-bit Random-UUID, nicht brute-force-bar
|
||||
- Magic-Bytes-Bypass beim Upload: HTML als image/png → blockiert
|
||||
- PDF-Generation mit injizierten manualValues: kein XSS-Vektor (PDFs sind keine Web-Renderer)
|
||||
- Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403
|
||||
- Query-Filter-Override (`?customerId=X`) → vom Portal-Filter ignoriert
|
||||
|
||||
---
|
||||
|
||||
## 📋 Bewusst NICHT gemacht (Trade-off, aber dokumentiert)
|
||||
|
||||
- **Signierte URLs mit kurzlebigen Download-Tokens** statt JWT-im-Query
|
||||
(verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
|
||||
`<a href>`-Downloads ohne JS – v1.2-Item.
|
||||
- **`/api/contracts/:id` GET liefert 404 für nicht-existente IDs**
|
||||
(Existence-Probing). Vereinheitlichung auf 403 wäre sauberer; da
|
||||
Contract-IDs aber nicht direkt mit personenbezogenen Daten korrelieren,
|
||||
niedrig-Prio.
|
||||
- **Prisma-Error-Leaks in anderen Admin-Endpoints** (z. B. `addInvoice`
|
||||
bei Validation-Fehler) – Defense-in-Depth-Kandidat, aber nur Admin-
|
||||
erreichbar.
|
||||
- **TipTap-Link-Tool**: `javascript:`-Protokoll blockieren (Admin-only
|
||||
erreichbar, niedrig-Prio).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Production-Deployment-Checkliste
|
||||
|
||||
Vor dem öffentlichen Schalten muss in der Production-`.env`:
|
||||
|
||||
- `JWT_SECRET` rotieren: `openssl rand -hex 64`
|
||||
- `ENCRYPTION_KEY` rotieren: `openssl rand -hex 32` (genau 64 Hex-Zeichen)
|
||||
- `NODE_ENV=production`
|
||||
- `CORS_ORIGINS=https://deine-domain.de` (oder leer für Same-Origin)
|
||||
- `LISTEN_ADDR=127.0.0.1` (nur lokaler Reverse-Proxy darf connecten)
|
||||
- Reverse-Proxy (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For`
|
||||
hart auf die echte Client-IP gesetzt wird (nicht angefügt) – sonst
|
||||
Rate-Limit-Bypass möglich.
|
||||
- Manuelle Test-Checkliste aus [TESTING.md](./TESTING.md) einmal komplett
|
||||
durchklicken.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Lazy Password-Hash-Upgrade
|
||||
|
||||
Bestandsuser mit bcrypt-Cost 10 (aus der Installation) werden beim ersten
|
||||
Login transparent auf Cost 12 rehashed. Damit gleicht sich die
|
||||
Antwortzeit beim Login automatisch der Dummy-bcrypt-Zeit (Cost 12) an –
|
||||
Login-Timing-Side-Channels schließen sich von alleine im Lauf der ersten
|
||||
Wochen nach Deployment.
|
||||
|
||||
---
|
||||
|
||||
## 🗨️ Lehre aus der Session
|
||||
|
||||
Statische Audit-Agents finden ca. 70 % der Findings, die letzten ~30 %
|
||||
brauchten Live-Tests gegen den laufenden Server. Sie kennen den exakten
|
||||
Permission-State der DB nicht (raten z. B., dass `gdpr:export` Portal-
|
||||
User-zugänglich sei – war's nicht), übersehen aber, dass ein
|
||||
Daten-Sanitizer einen Permission-Check vortäuschen kann (Runde 4 / 6).
|
||||
|
||||
**Take-away:** „Code sieht sicher aus" ≠ „Server verhält sich sicher".
|
||||
Vor jedem Launch mit echten Tokens probieren.
|
||||
|
||||
---
|
||||
|
||||
## 📑 Commit-Historie
|
||||
|
||||
| Commit | Runde | Hauptthema |
|
||||
| --------- | ------- | -------------------------------------------------------------- |
|
||||
| (mehrere) | 1 + 2 | Erste Review-Welle, dokumentiert in SECURITY-REVIEW.md |
|
||||
| (mehrere) | 3 | JWT alg, trust-proxy, Invoice/PDF IDOR, Attachment, Provider, SMTP-CRLF, bcrypt |
|
||||
| `334c408` | 4 | 9 Live-IDORs (customer.* + gdpr.*) + Error-Handler |
|
||||
| `8be9bae` | 5 | Uploads-Auth + Login-Timing + XSS |
|
||||
| `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle |
|
||||
| `12b9abe` | 7 | SSRF-Schutz + Logout |
|
||||
| `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership |
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
# Security-Review vor 1.0.0
|
||||
|
||||
> 📌 **Diese Datei dokumentiert nur die ersten 2 Runden ausführlich.**
|
||||
> Die vollständige Hardening-Story über alle **8 Runden** inkl. Live-Test-
|
||||
> Tabellen findest du in **[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**.
|
||||
|
||||
> **Version 2** – dieser Review wurde in 2 Runden durchgeführt.
|
||||
> Runde 1: erste kritische Findings (CORS, Helmet, JWT-Fallback, grobes IDOR, XSS, Data Exposure).
|
||||
> Runde 2 (weiter unten): **Deep-Dive** mit parallelen Audit-Agents – fand weitere IDOR-Stellen, Mass Assignment, Zip-Slip, Path-Traversal.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,20 @@
|
|||
/**
|
||||
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
||||
*
|
||||
* Geht über `GET /api/files/download?path=...` – der Backend-Controller
|
||||
* macht einen Per-File-Ownership-Check (Pfad → Resource → canAccessCustomer
|
||||
* / canAccessContract). Damit kann auch ein eingeloggter User keine
|
||||
* fremden Dateien abrufen, selbst wenn er den Pfad kennen würde.
|
||||
* `/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.
|
||||
*
|
||||
* <a href> und window.open senden keinen Authorization-Header, daher
|
||||
* Token als Query-Parameter (auth-Middleware akzeptiert `?token=<jwt>`).
|
||||
*
|
||||
* Trade-off: Tokens in URLs können in Logs/Referrer landen. Eine
|
||||
* sauberere Lösung mit kurzlebigen Download-Tokens (signierte URLs)
|
||||
* wäre v1.1-Item.
|
||||
* 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 normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||||
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
|
||||
const base = `/api${path.startsWith('/') ? path : '/' + path}`;
|
||||
if (!token) return base;
|
||||
return `${base}&token=${encodeURIComponent(token)}`;
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
return `${base}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue