Security-Hardening Runde 8: DNS-Rebinding + Per-File-Ownership
Loose Ends aus Runde 5/7 abgearbeitet.
🛡 DNS-Rebinding-Schutz in SSRF-Guard
- safeResolveHost() löst Hostname zu IPv4+IPv6 auf, prüft jede IP
gegen die Block-Liste, gibt {ip, servername} zurück.
- Caller (test-connection, test-mail-access) übergibt host=ip plus
servername=hostname an die Mail-Services. Damit kann ein zweiter
DNS-Lookup zur Connection-Zeit nicht plötzlich auf interne IPs
umlenken (rebound-Attack).
- ImapCredentials/SmtpCredentials um optionales servername-Feld
erweitert; Services nutzen es als TLS-SNI / Cert-Validation-Hint.
🔒 Per-File-Ownership-Check (DSGVO-Härtung)
- express.static('/api/uploads') ersetzt durch GET /api/files/download
mit Pfad→Resource→Owner-Mapping in fileDownload.service.ts.
- 12 subDir-Mappings (bank-cards, documents, contract-documents,
invoices, cancellation-*, authorizations, business-/commercial-/
privacy-, pdf-templates).
- canAccessCustomer / canAccessContract / Permission-Check je nach
Owner-Typ. Portal-User sieht jetzt nur eigene Dateien, selbst wenn
er fremde Filenames kennt.
- Backwards-Compat: /api/uploads/* bleibt als Shim erhalten, ruft
intern denselben Owner-Check.
- Frontend fileUrl() zeigt auf /api/files/download?path=...&token=...
Live-verifiziert:
- Eigene Datei: 200, random Pfad: 404, ../etc/passwd: 400, kein
Token: 401, Backwards-Compat-Shim: 200.
- DNS-Rebinding: nip.io-Hostname mit interner Target-IP wird via
DNS-Lookup geblockt; gmail.com (legitim) geht durch.
Bewusst nicht gemacht:
- Signierte URLs mit kurzlebigen Download-Tokens – v1.2-Item, da
invasiv für <a href>-Flows ohne JS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
12b9abe979
commit
d063d67282
|
|
@ -7,7 +7,7 @@ import { ApiResponse } from '../types/index.js';
|
||||||
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
import { assertAllowedHost } from '../utils/ssrfGuard.js';
|
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
@ -119,13 +119,15 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||||
domain: req.body.domain,
|
domain: req.body.domain,
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
// SSRF-Guard: testData.apiUrl-Hostname prüfen
|
// 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.
|
||||||
if (testData?.apiUrl) {
|
if (testData?.apiUrl) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(testData.apiUrl);
|
const url = new URL(testData.apiUrl);
|
||||||
assertAllowedHost(url.hostname, 'apiUrl-Host');
|
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.message.includes('geblockte')) {
|
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
||||||
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -229,12 +231,17 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF-Guard: Wenn der Host vom Body kommt, blockieren wir Cloud-Metadata
|
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
|
||||||
// und Reserved-Ranges. Loopback/Private-Ranges bleiben erlaubt für
|
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
||||||
// legitime Plesk/Postfix-Setups.
|
// 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 };
|
||||||
try {
|
try {
|
||||||
assertAllowedHost(smtpServer, 'SMTP-Server');
|
[smtpResolved, imapResolved] = await Promise.all([
|
||||||
assertAllowedHost(imapServer, 'IMAP-Server');
|
safeResolveHost(smtpServer, 'SMTP-Server'),
|
||||||
|
safeResolveHost(imapServer, 'IMAP-Server'),
|
||||||
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -245,22 +252,24 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
||||||
|
|
||||||
// IMAP testen
|
// IMAP testen
|
||||||
const imapCredentials: ImapCredentials = {
|
const imapCredentials: ImapCredentials = {
|
||||||
host: imapServer,
|
host: imapResolved.ip,
|
||||||
port: imapPort,
|
port: imapPort,
|
||||||
user: emailAddress,
|
user: emailAddress,
|
||||||
password,
|
password,
|
||||||
encryption: imapEncryption,
|
encryption: imapEncryption,
|
||||||
allowSelfSignedCerts,
|
allowSelfSignedCerts,
|
||||||
|
servername: imapResolved.servername,
|
||||||
};
|
};
|
||||||
|
|
||||||
// SMTP testen
|
// SMTP testen
|
||||||
const smtpCredentials: SmtpCredentials = {
|
const smtpCredentials: SmtpCredentials = {
|
||||||
host: smtpServer,
|
host: smtpResolved.ip,
|
||||||
port: smtpPort,
|
port: smtpPort,
|
||||||
user: emailAddress,
|
user: emailAddress,
|
||||||
password,
|
password,
|
||||||
encryption: smtpEncryption,
|
encryption: smtpEncryption,
|
||||||
allowSelfSignedCerts,
|
allowSelfSignedCerts,
|
||||||
|
servername: smtpResolved.servername,
|
||||||
};
|
};
|
||||||
|
|
||||||
let imapResult: { success: boolean; error?: string } = { success: false };
|
let imapResult: { success: boolean; error?: string } = { success: false };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
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,6 +34,7 @@ import emailLogRoutes from './routes/emailLog.routes.js';
|
||||||
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||||
import birthdayRoutes from './routes/birthday.routes.js';
|
import birthdayRoutes from './routes/birthday.routes.js';
|
||||||
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||||
|
import { downloadFile } from './controllers/fileDownload.controller.js';
|
||||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
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';
|
||||||
|
|
@ -101,12 +102,28 @@ app.use(express.json({ limit: '5mb' }));
|
||||||
app.use(auditContextMiddleware);
|
app.use(auditContextMiddleware);
|
||||||
app.use(auditMiddleware);
|
app.use(auditMiddleware);
|
||||||
|
|
||||||
// Statische Dateien für Uploads – NUR für authentifizierte User.
|
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
|
||||||
// authenticate-Middleware unterstützt ?token=... Query-Parameter für direkte
|
// `/api/uploads/*` express.static).
|
||||||
// <a href>-Downloads, bei denen der Browser keinen Authorization-Header sendet.
|
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
|
||||||
// Ohne diesen Schutz könnte jeder per Datei-Name-Enumeration sensible PDFs
|
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
|
||||||
// (Ausweise, Kündigungsbestätigungen, Bankkarten) abrufen – DSGVO-GAU.
|
// und prüft canAccessCustomer/canAccessContract – damit kann ein Portal-Kunde
|
||||||
app.use('/api/uploads', authenticate as any, express.static(path.join(process.cwd(), 'uploads')));
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
// Öffentliche Routes (OHNE Authentifizierung)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
app.use('/api/public/consent', consentPublicRoutes);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* 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,6 +14,9 @@ export interface ImapCredentials {
|
||||||
password: string;
|
password: string;
|
||||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,6 +32,12 @@ function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown>
|
||||||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||||
const options: Record<string, unknown> = { rejectUnauthorized };
|
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) {
|
if (credentials.allowSelfSignedCerts) {
|
||||||
options.minVersion = 'TLSv1';
|
options.minVersion = 'TLSv1';
|
||||||
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ export interface SmtpCredentials {
|
||||||
password: string;
|
password: string;
|
||||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
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
|
// Anhang-Interface
|
||||||
|
|
@ -94,7 +98,7 @@ export async function sendEmail(
|
||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
requireTLS?: boolean;
|
requireTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
|
|
@ -116,6 +120,11 @@ export async function sendEmail(
|
||||||
// TLS-Optionen nur wenn nicht NONE
|
// TLS-Optionen nur wenn nicht NONE
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
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) {
|
if (credentials.allowSelfSignedCerts) {
|
||||||
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
||||||
transportOptions.tls.minVersion = 'TLSv1';
|
transportOptions.tls.minVersion = 'TLSv1';
|
||||||
|
|
@ -303,7 +312,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
greetingTimeout: number;
|
greetingTimeout: number;
|
||||||
|
|
@ -321,6 +330,9 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
transportOptions.tls = { rejectUnauthorized };
|
||||||
|
if (credentials.servername) {
|
||||||
|
transportOptions.tls.servername = credentials.servername;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
transportOptions.ignoreTLS = true;
|
transportOptions.ignoreTLS = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,53 @@ export function assertAllowedHost(host: string | null | undefined, label = 'Host
|
||||||
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
|
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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,39 @@ 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 8 – Loose Ends (DNS-Rebinding + Per-File-Ownership):**
|
||||||
|
- **DNS-Rebinding-Schutz** in test-connection / test-mail-access:
|
||||||
|
Hostnames werden vor Connect via `dns.resolve4/6` aufgelöst und
|
||||||
|
jede IP gegen die SSRF-Block-Liste geprüft. Connection läuft
|
||||||
|
anschließend gegen die IP, der ursprüngliche Hostname als
|
||||||
|
`tls.servername` für SNI/Cert-Validation. Ein zweiter DNS-Lookup
|
||||||
|
kann keine geblockte IP unterschieben.
|
||||||
|
- **Per-File-Ownership-Check** statt freiem static-Handler:
|
||||||
|
`app.use('/api/uploads', authenticate, express.static)` wird
|
||||||
|
ersetzt durch `GET /api/files/download?path=...`. Der
|
||||||
|
Controller mappt den Pfad via DB-Lookup auf Customer/Contract
|
||||||
|
und delegiert an `canAccessCustomer`/`canAccessContract` –
|
||||||
|
ein eingeloggter Portal-Kunde kann jetzt nur seine eigenen
|
||||||
|
(oder vertretene mit Vollmacht) Dateien laden, selbst wenn
|
||||||
|
er fremde Filenames irgendwo mitgeschnitten hätte.
|
||||||
|
`/api/uploads/*` bleibt als Backwards-Compat-Shim erhalten,
|
||||||
|
ruft aber denselben Owner-Check.
|
||||||
|
- 12 subDir-Mappings: bank-cards, documents, business-/commercial-/
|
||||||
|
privacy-, authorizations, contract-documents, invoices, alle
|
||||||
|
4 cancellation-* + pdf-templates (admin-only).
|
||||||
|
- Frontend `fileUrl()` zeigt jetzt auf den neuen Endpoint.
|
||||||
|
Path-Traversal wird sowohl per Format-Validation (begin /uploads/,
|
||||||
|
no '..') als auch durch absoluten Path-Vergleich gegen uploadsRoot
|
||||||
|
geblockt.
|
||||||
|
- Live-verifiziert: Portal-User lädt eigene Contract-Datei (200),
|
||||||
|
random Pfad (404), Traversal (400), kein Token (401), Backwards-
|
||||||
|
Compat-Shim (200).
|
||||||
|
|
||||||
|
**Bewusst NICHT gemacht (für v1.2):**
|
||||||
|
- Signierte URLs mit kurzlebigen Download-Tokens statt JWT-im-Query
|
||||||
|
(verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
|
||||||
|
<a href>-Downloads ohne JS, lassen wir bis später.
|
||||||
|
|
||||||
- **Runde 7 – Letzter Schliff (SSRF + Logout):**
|
- **Runde 7 – Letzter Schliff (SSRF + Logout):**
|
||||||
- **SSRF-Schutz** in `test-connection` und `test-mail-access`: ein
|
- **SSRF-Schutz** in `test-connection` und `test-mail-access`: ein
|
||||||
Admin-User konnte über die Plesk-API-URL bzw. SMTP/IMAP-Server-Felder
|
Admin-User konnte über die Plesk-API-URL bzw. SMTP/IMAP-Server-Felder
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
/**
|
/**
|
||||||
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
||||||
*
|
*
|
||||||
* `/api/uploads/*` läuft hinter authenticate-Middleware, aber <a href> und
|
* Geht über `GET /api/files/download?path=...` – der Backend-Controller
|
||||||
* window.open senden keinen Authorization-Header. Darum hängen wir das JWT
|
* macht einen Per-File-Ownership-Check (Pfad → Resource → canAccessCustomer
|
||||||
* als Query-Parameter an. Die authenticate-Middleware akzeptiert
|
* / canAccessContract). Damit kann auch ein eingeloggter User keine
|
||||||
* `?token=<jwt>` neben dem Header.
|
* fremden Dateien abrufen, selbst wenn er den Pfad kennen würde.
|
||||||
*
|
*
|
||||||
* Trade-off: Tokens in URLs landen potenziell in Logs/Referrer. Für eine
|
* <a href> und window.open senden keinen Authorization-Header, daher
|
||||||
* saubere Lösung (kurzlebige Download-Tokens) wäre ein separater Endpoint
|
* Token als Query-Parameter (auth-Middleware akzeptiert `?token=<jwt>`).
|
||||||
* nötig – TODO für v1.1.
|
*
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function fileUrl(path: string | null | undefined): string {
|
export function fileUrl(path: string | null | undefined): string {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const base = `/api${path.startsWith('/') ? path : '/' + path}`;
|
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||||||
|
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
|
||||||
if (!token) return base;
|
if (!token) return base;
|
||||||
const separator = base.includes('?') ? '&' : '?';
|
return `${base}&token=${encodeURIComponent(token)}`;
|
||||||
return `${base}${separator}token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue