Files
opencrm/backend/src/controllers/emailProvider.controller.ts
T
duffyduckandClaude Opus 4.7 c3321a2aa9 Pentest 48.1 MEDIUM + 50.1 MEDIUM: customerEmailLabel-Strip + SSRF strict
48.1 (XSS in customerEmailLabel):
- Neuer sanitizeCustomerEmailLabel-Helper (stripHtml + trim +
  60-Zeichen-Cap)
- Eingesetzt in createProviderConfig + updateProviderConfig
  (Write-Pfad) und getProviderPublicSettings (Read-Defensive)
- Damit landet kein <script>/<img onerror>/<svg onload> mehr roh
  in der DB, das Längen-Limit ist serverseitig erzwungen, und
  Alt-Daten kommen über /public-settings ebenfalls gestrippt raus.

50.1 (SSRF, unvollständige Blockliste bei test-connection):
- safeResolveHost + assertAllowedHost akzeptieren jetzt
  { strict: boolean }. strict=true → isPrivateOrBlockedHost
  (sperrt 127/8, 10/8, 172.16/12, 192.168/16, ::1, fc00::/7
  unabhängig von SSRF_BLOCK_PRIVATE_IPS).
- test-connection und test-mail-access nutzen strict=true per
  Default. Opt-out via env SSRF_ALLOW_INTERNAL_TESTING=true
  für On-Prem mit internem Plesk.
- Defense-in-Depth: assertAllowedHost wird jetzt auch VOR der
  DNS-Resolution auf den Hostname selbst angewendet, damit
  Block-Hostnames (z.B. "metadata.google.internal", "localhost")
  nicht via custom-DNS umgangen werden können.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 18:29:08 +02:00

425 lines
15 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.
// ==================== EMAIL PROVIDER CONTROLLER ====================
import { Request, Response } from 'express';
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
import { logChange } from '../services/audit.service.js';
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 { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// ==================== CONFIG CRUD ====================
export async function getProviderConfigs(req: Request, res: Response): Promise<void> {
try {
const configs = await emailProviderService.getAllProviderConfigs();
res.json({ success: true, data: configs } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Email-Provider',
} as ApiResponse);
}
}
export async function getProviderConfig(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
const config = await emailProviderService.getProviderConfigById(id);
if (!config) {
res.status(404).json({
success: false,
error: 'Email-Provider nicht gefunden',
} as ApiResponse);
return;
}
res.json({ success: true, data: config } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden des Email-Providers',
} as ApiResponse);
}
}
export async function createProviderConfig(req: Request, res: Response): Promise<void> {
try {
const config = await emailProviderService.createProviderConfig(req.body);
await logChange({
req, action: 'CREATE', resourceType: 'EmailProviderConfig',
resourceId: config.id.toString(),
label: `E-Mail-Provider ${config.name} angelegt`,
});
res.status(201).json({ success: true, data: config } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Email-Providers',
} as ApiResponse);
}
}
export async function updateProviderConfig(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
const config = await emailProviderService.updateProviderConfig(id, req.body);
await logChange({
req, action: 'UPDATE', resourceType: 'EmailProviderConfig',
resourceId: id.toString(),
label: `E-Mail-Provider ${config.name} aktualisiert`,
});
res.json({ success: true, data: config } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Email-Providers',
} as ApiResponse);
}
}
export async function deleteProviderConfig(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
const config = await emailProviderService.getProviderConfigById(id);
await emailProviderService.deleteProviderConfig(id);
await logChange({
req, action: 'DELETE', resourceType: 'EmailProviderConfig',
resourceId: id.toString(),
label: `E-Mail-Provider ${config?.name || id} gelöscht`,
});
res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Email-Providers',
} as ApiResponse);
}
}
// ==================== EMAIL OPERATIONS ====================
export async function testConnection(req: Request, res: Response): Promise<void> {
try {
// Option 1: Provider-ID für gespeicherten Provider
const id = req.body?.id ? parseInt(req.body.id) : undefined;
// Option 2: Testdaten aus Body (für Test im Modal mit ungespeicherten Daten)
const testData = req.body && req.body.type ? {
type: req.body.type as 'PLESK' | 'CPANEL' | 'DIRECTADMIN',
apiUrl: req.body.apiUrl,
apiKey: req.body.apiKey || undefined,
username: req.body.username || undefined,
password: req.body.password || undefined,
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.
// Pentest 50.1: strict=true test-connection darf NIE auf private IPs,
// Loopback oder Cloud-Metadata zeigen, unabhängig von
// SSRF_BLOCK_PRIVATE_IPS. On-Prem mit echtem internen Plesk kann das
// per SSRF_ALLOW_INTERNAL_TESTING=true opt-outen (Default: blockiert).
const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true';
if (testData?.apiUrl) {
try {
const url = new URL(testData.apiUrl);
await safeResolveHost(url.hostname, 'apiUrl-Host', { strict: !allowInternalTesting });
} catch (err) {
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err.message,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { apiUrl: testData.apiUrl },
});
res.status(400).json({ success: false, error: err.message } as ApiResponse);
return;
}
// URL-Parse-Fehler ignorieren Backend reagiert sowieso mit Fehler
}
}
const result = await emailProviderService.testProviderConnection({ id, testData });
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Verbindungstest fehlgeschlagen',
} as ApiResponse);
}
}
/**
* Testet IMAP + SMTP-Zugang für die System-E-Mail eines Providers.
* - Option A: Provider-ID + optional überschreibendes Passwort aus Body (Modal)
* - Option B: Testdaten komplett aus Body (beim Anlegen, noch nicht gespeichert)
*/
export async function testMailAccess(req: Request, res: Response): Promise<void> {
try {
const id = req.body?.id ? parseInt(req.body.id) : undefined;
const bodyEmail = typeof req.body?.systemEmailAddress === 'string' ? req.body.systemEmailAddress : undefined;
const bodyPassword = typeof req.body?.systemEmailPassword === 'string' ? req.body.systemEmailPassword : undefined;
let emailAddress: string | undefined;
let password: string | undefined;
let smtpServer: string;
let smtpPort: number;
let imapServer: string;
let imapPort: number;
let smtpEncryption: 'SSL' | 'STARTTLS' | 'NONE';
let imapEncryption: 'SSL' | 'STARTTLS' | 'NONE';
let allowSelfSignedCerts: boolean;
if (id) {
// Gespeicherten Provider laden
const config = await prisma.emailProviderConfig.findUnique({ where: { id } });
if (!config) {
res.status(404).json({ success: false, error: 'Provider nicht gefunden' } as ApiResponse);
return;
}
emailAddress = bodyEmail || config.systemEmailAddress || undefined;
if (bodyPassword) {
password = bodyPassword;
} else if (config.systemEmailPasswordEncrypted) {
try {
password = decrypt(config.systemEmailPasswordEncrypted);
} catch {
password = undefined;
}
}
// IMAP/SMTP-Settings vom Provider ableiten
const settings = await emailProviderService.getImapSmtpSettings();
if (!settings) {
res.status(400).json({ success: false, error: 'Keine IMAP/SMTP-Einstellungen verfügbar' } as ApiResponse);
return;
}
smtpServer = settings.smtpServer;
smtpPort = settings.smtpPort;
imapServer = settings.imapServer;
imapPort = settings.imapPort;
smtpEncryption = settings.smtpEncryption;
imapEncryption = settings.imapEncryption;
allowSelfSignedCerts = settings.allowSelfSignedCerts;
} else if (req.body?.apiUrl) {
// Formulardaten ohne gespeicherten Provider
emailAddress = bodyEmail;
password = bodyPassword;
try {
const url = new URL(req.body.apiUrl);
smtpServer = url.hostname;
imapServer = url.hostname;
} catch {
smtpServer = `mail.${req.body.domain || ''}`;
imapServer = smtpServer;
}
imapEncryption = (req.body.imapEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
smtpEncryption = (req.body.smtpEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
allowSelfSignedCerts = !!req.body.allowSelfSignedCerts;
imapPort = imapEncryption === 'SSL' ? 993 : 143;
smtpPort = smtpEncryption === 'SSL' ? 465 : smtpEncryption === 'STARTTLS' ? 587 : 25;
} else {
res.status(400).json({ success: false, error: 'Provider-ID oder Testdaten erforderlich' } as ApiResponse);
return;
}
if (!emailAddress || !password) {
res.status(400).json({
success: false,
error: 'System-E-Mail-Adresse und Passwort sind erforderlich',
} as ApiResponse);
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.
// Pentest 50.1 analog testConnection: strict, opt-out via env.
const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true';
let smtpResolved: { ip: string; servername: string };
let imapResolved: { ip: string; servername: string };
try {
[smtpResolved, imapResolved] = await Promise.all([
safeResolveHost(smtpServer, 'SMTP-Server', { strict: !allowInternalTesting }),
safeResolveHost(imapServer, 'IMAP-Server', { strict: !allowInternalTesting }),
]);
} catch (err) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { smtpServer, imapServer },
});
res.status(400).json({
success: false,
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
} as ApiResponse);
return;
}
// IMAP testen
const imapCredentials: ImapCredentials = {
host: imapResolved.ip,
port: imapPort,
user: emailAddress,
password,
encryption: imapEncryption,
allowSelfSignedCerts,
servername: imapResolved.servername,
};
// SMTP testen
const smtpCredentials: SmtpCredentials = {
host: smtpResolved.ip,
port: smtpPort,
user: emailAddress,
password,
encryption: smtpEncryption,
allowSelfSignedCerts,
servername: smtpResolved.servername,
};
let imapResult: { success: boolean; error?: string } = { success: false };
let smtpResult: { success: boolean; error?: string } = { success: false };
try {
await testImapConnection(imapCredentials);
imapResult = { success: true };
} catch (e) {
imapResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
}
try {
await testSmtpConnection(smtpCredentials);
smtpResult = { success: true };
} catch (e) {
smtpResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
}
res.json({
success: imapResult.success && smtpResult.success,
data: {
imap: {
...imapResult,
server: imapServer,
port: imapPort,
encryption: imapEncryption,
},
smtp: {
...smtpResult,
server: smtpServer,
port: smtpPort,
encryption: smtpEncryption,
},
user: emailAddress,
},
} as ApiResponse);
} catch (error) {
console.error('testMailAccess error:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Test',
} as ApiResponse);
}
}
export async function checkEmailExists(req: Request, res: Response): Promise<void> {
try {
const { localPart } = req.params;
const result = await emailProviderService.checkEmailExists(localPart);
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Prüfung',
} as ApiResponse);
}
}
export async function provisionEmail(req: Request, res: Response): Promise<void> {
try {
const { localPart, customerEmail } = req.body;
if (!localPart || !customerEmail) {
res.status(400).json({
success: false,
error: 'localPart und customerEmail sind erforderlich',
} as ApiResponse);
return;
}
const result = await emailProviderService.provisionEmail(localPart, customerEmail);
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Provisionierung',
} as ApiResponse);
}
}
export async function deprovisionEmail(req: Request, res: Response): Promise<void> {
try {
const { localPart } = req.params;
const result = await emailProviderService.deprovisionEmail(localPart);
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der E-Mail',
} as ApiResponse);
}
}
export async function getProviderDomain(req: Request, res: Response): Promise<void> {
try {
const domain = await emailProviderService.getProviderDomain();
res.json({ success: true, data: { domain } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Domain',
} as ApiResponse);
}
}
/**
* Öffentliche Provider-Einstellungen für die Frontend-UI:
* Domain + Label für Kunden-E-Mail-Adressen.
* Auch für Nicht-Admin-Mitarbeiter verfügbar, da nur UI-Labels.
*/
export async function getPublicSettings(req: Request, res: Response): Promise<void> {
try {
const settings = await emailProviderService.getProviderPublicSettings();
res.json({ success: true, data: settings } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Einstellungen',
} as ApiResponse);
}
}