6b804cdc82
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>
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
// ==================== SMTP SERVICE ====================
|
||
// Service für E-Mail-Versand via SMTP
|
||
|
||
import nodemailer from 'nodemailer';
|
||
import type { Transporter } from 'nodemailer';
|
||
import MailComposer from 'nodemailer/lib/mail-composer';
|
||
|
||
// Verschlüsselungstyp
|
||
export type MailEncryption = 'SSL' | 'STARTTLS' | 'NONE';
|
||
|
||
export interface SmtpCredentials {
|
||
host: string;
|
||
port: number;
|
||
user: string;
|
||
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
|
||
export interface EmailAttachment {
|
||
filename: string;
|
||
content: string; // Base64-kodierter Inhalt
|
||
contentType?: string; // MIME-Type (z.B. 'application/pdf')
|
||
}
|
||
|
||
export interface SendEmailParams {
|
||
to: string | string[];
|
||
cc?: string | string[];
|
||
subject: string;
|
||
text?: string;
|
||
html?: string;
|
||
inReplyTo?: string; // Message-ID der E-Mail auf die geantwortet wird
|
||
references?: string[]; // Thread-Referenzen
|
||
attachments?: EmailAttachment[]; // Anhänge
|
||
}
|
||
|
||
export interface SendEmailResult {
|
||
success: boolean;
|
||
messageId?: string;
|
||
rawEmail?: Buffer; // RFC 5322 formatierte E-Mail für IMAP-Speicherung
|
||
error?: string;
|
||
}
|
||
|
||
// Optionaler Logging-Kontext
|
||
export interface EmailLogContext {
|
||
context: string; // z.B. "consent-link", "authorization-request", "customer-email"
|
||
customerId?: number;
|
||
triggeredBy?: string; // User-Email
|
||
}
|
||
|
||
// Security: zentrale CRLF-Prüfung gegen SMTP-Header-Injection.
|
||
// Alle Felder, die als Header ausgehen (to/cc/subject/replyTo/references/from),
|
||
// werden hier geprüft – egal ob der Caller aus cachedEmail, birthday, gdpr,
|
||
// consent-public oder auth kommt.
|
||
function containsCRLF(value: unknown): boolean {
|
||
if (typeof value === 'string') return /[\r\n]/.test(value);
|
||
if (Array.isArray(value)) return value.some(containsCRLF);
|
||
return false;
|
||
}
|
||
|
||
// E-Mail senden
|
||
export async function sendEmail(
|
||
credentials: SmtpCredentials,
|
||
fromAddress: string,
|
||
params: SendEmailParams,
|
||
logContext?: EmailLogContext
|
||
): Promise<SendEmailResult> {
|
||
// Header-Injection-Guard (defensiv: Absender, Empfänger, Subject)
|
||
if (
|
||
containsCRLF(fromAddress) ||
|
||
containsCRLF(params.to) ||
|
||
containsCRLF(params.cc) ||
|
||
containsCRLF(params.subject) ||
|
||
containsCRLF(params.inReplyTo) ||
|
||
containsCRLF(params.references)
|
||
) {
|
||
return {
|
||
success: false,
|
||
error: 'Ungültige Zeichen in E-Mail-Header-Feldern (CRLF nicht erlaubt)',
|
||
};
|
||
}
|
||
|
||
// Verschlüsselungs-Einstellungen basierend auf Modus
|
||
const encryption = credentials.encryption ?? 'SSL';
|
||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||
|
||
// Transport-Optionen je nach Verschlüsselungstyp
|
||
// SSL: secure=true (implicit TLS, Port 465)
|
||
// STARTTLS: secure=false (upgrades to TLS, Port 587)
|
||
// NONE: secure=false + ignoreTLS=true (no encryption, Port 25)
|
||
const transportOptions: nodemailer.TransportOptions & {
|
||
host: string;
|
||
port: number;
|
||
secure: boolean;
|
||
auth: { user: string; pass: string };
|
||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||
ignoreTLS?: boolean;
|
||
requireTLS?: boolean;
|
||
connectionTimeout: number;
|
||
greetingTimeout: number;
|
||
socketTimeout: number;
|
||
} = {
|
||
host: credentials.host,
|
||
port: credentials.port,
|
||
secure: encryption === 'SSL',
|
||
auth: {
|
||
user: credentials.user,
|
||
pass: credentials.password,
|
||
},
|
||
connectionTimeout: 10000,
|
||
greetingTimeout: 10000,
|
||
socketTimeout: 30000,
|
||
};
|
||
|
||
// 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';
|
||
transportOptions.tls.ciphers = 'DEFAULT:@SECLEVEL=0';
|
||
}
|
||
} else {
|
||
// Keine Verschlüsselung: STARTTLS ignorieren
|
||
transportOptions.ignoreTLS = true;
|
||
}
|
||
|
||
// Bei STARTTLS: requireTLS erzwingen
|
||
if (encryption === 'STARTTLS') {
|
||
transportOptions.requireTLS = true;
|
||
}
|
||
|
||
// Debug-Logging für Entwicklung
|
||
console.log(`[SMTP] Connecting to ${credentials.host}:${credentials.port} (${encryption}), user: ${credentials.user}`);
|
||
|
||
// Transporter erstellen
|
||
const transporter: Transporter = nodemailer.createTransport(transportOptions);
|
||
|
||
try {
|
||
// E-Mail-Optionen zusammenstellen
|
||
const mailOptions: nodemailer.SendMailOptions = {
|
||
from: fromAddress,
|
||
to: Array.isArray(params.to) ? params.to.join(', ') : params.to,
|
||
subject: params.subject,
|
||
};
|
||
|
||
// CC hinzufügen falls vorhanden
|
||
if (params.cc) {
|
||
mailOptions.cc = Array.isArray(params.cc) ? params.cc.join(', ') : params.cc;
|
||
}
|
||
|
||
// Body hinzufügen
|
||
if (params.html) {
|
||
mailOptions.html = params.html;
|
||
// Auch Text-Version für Clients ohne HTML-Support
|
||
mailOptions.text = params.text || stripHtml(params.html);
|
||
} else if (params.text) {
|
||
mailOptions.text = params.text;
|
||
}
|
||
|
||
// Threading-Header für Antworten
|
||
if (params.inReplyTo) {
|
||
mailOptions.inReplyTo = params.inReplyTo;
|
||
}
|
||
if (params.references && params.references.length > 0) {
|
||
mailOptions.references = params.references.join(' ');
|
||
}
|
||
|
||
// Anhänge hinzufügen
|
||
if (params.attachments && params.attachments.length > 0) {
|
||
mailOptions.attachments = params.attachments.map((att) => ({
|
||
filename: att.filename,
|
||
content: Buffer.from(att.content, 'base64'),
|
||
contentType: att.contentType,
|
||
}));
|
||
}
|
||
|
||
// E-Mail senden
|
||
const result = await transporter.sendMail(mailOptions);
|
||
|
||
// Raw E-Mail für IMAP-Speicherung bauen (mit tatsächlicher Message-ID)
|
||
let rawEmail: Buffer | undefined;
|
||
try {
|
||
const composerOptions = {
|
||
...mailOptions,
|
||
messageId: result.messageId, // Tatsächliche Message-ID vom Server
|
||
};
|
||
const composer = new MailComposer(composerOptions);
|
||
rawEmail = await composer.compile().build();
|
||
} catch (compileError) {
|
||
console.error('Error compiling raw email:', compileError);
|
||
// Nicht kritisch - E-Mail wurde trotzdem gesendet
|
||
}
|
||
|
||
// E-Mail-Log erstellen (async, nicht blockierend)
|
||
if (logContext) {
|
||
import('./emailLog.service.js').then(({ createEmailLog }) => {
|
||
createEmailLog({
|
||
fromAddress,
|
||
toAddress: Array.isArray(params.to) ? params.to.join(', ') : params.to,
|
||
subject: params.subject,
|
||
context: logContext.context,
|
||
customerId: logContext.customerId,
|
||
triggeredBy: logContext.triggeredBy,
|
||
smtpServer: credentials.host,
|
||
smtpPort: credentials.port,
|
||
smtpEncryption: credentials.encryption ?? 'SSL',
|
||
smtpUser: credentials.user,
|
||
success: true,
|
||
messageId: result.messageId,
|
||
smtpResponse: result.response,
|
||
}).catch((err) => console.error('EmailLog write error:', err));
|
||
});
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
messageId: result.messageId,
|
||
rawEmail,
|
||
};
|
||
} catch (error) {
|
||
console.error('SMTP sendEmail error:', error);
|
||
|
||
// Bessere Fehlermeldungen
|
||
let errorMessage = 'Unbekannter Fehler beim E-Mail-Versand';
|
||
|
||
if (error instanceof Error) {
|
||
const msg = error.message.toLowerCase();
|
||
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
|
||
|
||
if (msg.includes('authentication') || msg.includes('auth') || msg.includes('login')) {
|
||
errorMessage = 'SMTP-Authentifizierung fehlgeschlagen - Zugangsdaten prüfen';
|
||
} else if (msg.includes('econnrefused') || errorCode === 'econnrefused') {
|
||
errorMessage = `SMTP-Server nicht erreichbar: ${credentials.host}:${credentials.port} - Verbindung verweigert`;
|
||
} else if (msg.includes('greeting never received') || msg.includes('etimedout') || errorCode === 'etimedout') {
|
||
// Detaillierte Fehlermeldung je nach Port/Verschlüsselung
|
||
const enc = credentials.encryption ?? 'SSL';
|
||
if (enc === 'STARTTLS' && credentials.port === 587) {
|
||
errorMessage = `SMTP-Verbindung zu Port 587 fehlgeschlagen - STARTTLS (Submission) ist möglicherweise nicht aktiviert auf ${credentials.host}`;
|
||
} else if (enc === 'NONE' && credentials.port === 25) {
|
||
errorMessage = `SMTP-Verbindung zu Port 25 fehlgeschlagen - Port möglicherweise blockiert oder nicht erreichbar auf ${credentials.host}`;
|
||
} else {
|
||
errorMessage = `SMTP-Verbindung zu ${credentials.host}:${credentials.port} fehlgeschlagen - Port nicht erreichbar oder Timeout`;
|
||
}
|
||
} else if (msg.includes('timeout')) {
|
||
errorMessage = `SMTP-Verbindung: Zeitüberschreitung bei ${credentials.host}:${credentials.port}`;
|
||
} else if (msg.includes('recipient') || msg.includes('rejected')) {
|
||
errorMessage = 'Empfänger-Adresse wurde vom Server abgelehnt';
|
||
} else if (msg.includes('certificate') || msg.includes('cert')) {
|
||
errorMessage = 'SSL-Zertifikatfehler - Aktiviere "Selbstsignierte Zertifikate erlauben" in den Provider-Einstellungen';
|
||
} else if (msg.includes('socket close') || msg.includes('socket hang up') || msg.includes('econnreset') || errorCode === 'econnreset') {
|
||
// Server schließt Verbindung unerwartet - oft TLS-Problem bei STARTTLS
|
||
const enc = credentials.encryption ?? 'SSL';
|
||
if (enc === 'STARTTLS') {
|
||
errorMessage = `SMTP-Verbindung abgebrochen bei STARTTLS - Aktiviere "Selbstsignierte Zertifikate erlauben" oder verwende SSL/TLS auf Port 465`;
|
||
} else {
|
||
errorMessage = `SMTP-Verbindung unerwartet geschlossen von ${credentials.host}:${credentials.port}`;
|
||
}
|
||
} else {
|
||
errorMessage = error.message;
|
||
}
|
||
}
|
||
|
||
// E-Mail-Log erstellen (Fehler)
|
||
if (logContext) {
|
||
import('./emailLog.service.js').then(({ createEmailLog }) => {
|
||
createEmailLog({
|
||
fromAddress,
|
||
toAddress: Array.isArray(params.to) ? params.to.join(', ') : params.to,
|
||
subject: params.subject,
|
||
context: logContext.context,
|
||
customerId: logContext.customerId,
|
||
triggeredBy: logContext.triggeredBy,
|
||
smtpServer: credentials.host,
|
||
smtpPort: credentials.port,
|
||
smtpEncryption: credentials.encryption ?? 'SSL',
|
||
smtpUser: credentials.user,
|
||
success: false,
|
||
errorMessage,
|
||
}).catch((err) => console.error('EmailLog write error:', err));
|
||
});
|
||
}
|
||
|
||
return {
|
||
success: false,
|
||
error: errorMessage,
|
||
};
|
||
} finally {
|
||
// Transporter schließen
|
||
transporter.close();
|
||
}
|
||
}
|
||
|
||
// SMTP-Verbindung testen
|
||
export async function testSmtpConnection(credentials: SmtpCredentials): Promise<void> {
|
||
// Verschlüsselungs-Einstellungen basierend auf Modus
|
||
const encryption = credentials.encryption ?? 'SSL';
|
||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||
|
||
const transportOptions: nodemailer.TransportOptions & {
|
||
host: string;
|
||
port: number;
|
||
secure: boolean;
|
||
auth: { user: string; pass: string };
|
||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||
ignoreTLS?: boolean;
|
||
connectionTimeout: number;
|
||
greetingTimeout: number;
|
||
} = {
|
||
host: credentials.host,
|
||
port: credentials.port,
|
||
secure: encryption === 'SSL',
|
||
auth: {
|
||
user: credentials.user,
|
||
pass: credentials.password,
|
||
},
|
||
connectionTimeout: 10000,
|
||
greetingTimeout: 10000,
|
||
};
|
||
|
||
if (encryption !== 'NONE') {
|
||
transportOptions.tls = { rejectUnauthorized };
|
||
if (credentials.servername) {
|
||
transportOptions.tls.servername = credentials.servername;
|
||
}
|
||
} else {
|
||
transportOptions.ignoreTLS = true;
|
||
}
|
||
|
||
const transporter: Transporter = nodemailer.createTransport(transportOptions);
|
||
|
||
try {
|
||
// Verbindung verifizieren
|
||
await transporter.verify();
|
||
} catch (error) {
|
||
if (error instanceof Error) {
|
||
const msg = error.message.toLowerCase();
|
||
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
|
||
|
||
if (msg.includes('authentication') || msg.includes('auth') || msg.includes('login')) {
|
||
throw new Error('SMTP-Authentifizierung fehlgeschlagen');
|
||
}
|
||
if (msg.includes('econnrefused') || errorCode === 'econnrefused') {
|
||
throw new Error(`SMTP-Server nicht erreichbar: ${credentials.host}:${credentials.port} - Verbindung verweigert`);
|
||
}
|
||
if (msg.includes('greeting never received') || msg.includes('etimedout') || errorCode === 'etimedout') {
|
||
if (encryption === 'STARTTLS' && credentials.port === 587) {
|
||
throw new Error(`SMTP Port 587 (STARTTLS/Submission) ist nicht erreichbar - In Plesk unter Tools & Settings > Mail Server Settings aktivieren`);
|
||
} else if (encryption === 'NONE' && credentials.port === 25) {
|
||
throw new Error(`SMTP Port 25 ist nicht erreichbar auf ${credentials.host}`);
|
||
} else {
|
||
throw new Error(`SMTP-Verbindung zu ${credentials.host}:${credentials.port} fehlgeschlagen - Port nicht erreichbar`);
|
||
}
|
||
}
|
||
if (msg.includes('certificate') || msg.includes('cert')) {
|
||
throw new Error('SSL-Zertifikatfehler - Aktiviere "Selbstsignierte Zertifikate erlauben"');
|
||
}
|
||
}
|
||
|
||
throw error;
|
||
} finally {
|
||
transporter.close();
|
||
}
|
||
}
|
||
|
||
// Helper: HTML zu Text konvertieren (einfache Version)
|
||
function stripHtml(html: string): string {
|
||
return html
|
||
// Zeilenumbrüche für Block-Elemente
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/p>/gi, '\n\n')
|
||
.replace(/<\/div>/gi, '\n')
|
||
.replace(/<\/li>/gi, '\n')
|
||
// Alle HTML-Tags entfernen
|
||
.replace(/<[^>]+>/g, '')
|
||
// HTML-Entities dekodieren
|
||
.replace(/ /g, ' ')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
// Mehrfache Leerzeilen reduzieren
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|