MEDIUM: PUT /api/stressfrei-emails/:id und 6 weitere Update- Endpunkte (platform, tariff, contractCategory, cancellationPeriod, contractDuration, email-providers) reichten req.body ungefiltert an Prisma. Gleiche Bug-Klasse wie das gefixte M1-Finding, sieben Stellen mehr. Nachgewiesen via provisionError-Feld ausserhalb des TS-Types. Fix: sieben Whitelists + pickXxxUpdate()-Helper in sanitize.ts, in den jeweiligen Controllern eingehängt. Reuse der bewährten pick()-Infrastruktur (Customer/User seit Runde 7). EmailProvider bewusst OHNE stripHtmlFromStrings, weil Passwörter und API-Keys legitim Sonderzeichen enthalten dürfen. Doku: SECURITY-HARDENING.md § Runde 110 + docs/todo.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
// ==================== 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';
|
||
import { pickEmailProviderUpdate } from '../utils/sanitize.js';
|
||
|
||
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);
|
||
// Pentest R110: Mass-Assignment-Whitelist. Kein stripHtml, weil das
|
||
// Payload Passwörter/API-Keys enthalten kann.
|
||
const config = await emailProviderService.updateProviderConfig(id, pickEmailProviderUpdate(req.body) as any);
|
||
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);
|
||
}
|
||
}
|