Files
opencrm/backend/src/services/smtpService.ts
T
duffyduck 6b804cdc82 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>
2026-05-01 07:59:19 +02:00

396 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==================== 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
// Mehrfache Leerzeilen reduzieren
.replace(/\n{3,}/g, '\n\n')
.trim();
}