diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 34fe55b1..fb6a2bac 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js'; import * as customerService from '../services/customer.service.js'; import * as authService from '../services/auth.service.js'; import { logChange } from '../services/audit.service.js'; +import { getConfiguredEmailDomains, emailUsesDomain } from '../services/emailProvider/emailProviderService.js'; import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { @@ -99,12 +100,23 @@ export async function createCustomer(req: Request, res: Response): Promise try { // Whitelist: nur erlaubte Felder aus req.body übernehmen const data: any = pickCustomerCreate(req.body); + // E-Mail ist Pflichtfeld. + if (!data.email || !String(data.email).trim()) { + res.status(400).json({ success: false, error: 'E-Mail-Adresse ist ein Pflichtfeld.' } as ApiResponse); + return; + } // Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als // SMTP-Header-Injection-Vektor in der DB (Pentest 29.4). if (data.email && !isValidEmail(data.email)) { res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); return; } + // Private E-Mail darf keine unserer verwalteten Provider-/Weiterleitungs- + // Domains verwenden. + if (emailUsesDomain(String(data.email), await getConfiguredEmailDomains())) { + res.status(400).json({ success: false, error: 'Diese E-Mail liegt auf einer verwalteten (Weiterleitungs-/Mailbox-)Domain und ist als private Kunden-E-Mail nicht zulässig.' } as ApiResponse); + return; + } if (data.portalEmail && !isValidEmail(data.portalEmail)) { res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); return; @@ -159,11 +171,21 @@ export async function updateCustomer(req: Request, res: Response): Promise res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); return; } + // Private E-Mail darf keine verwaltete Provider-/Weiterleitungs-Domain sein. + if (req.body?.email && emailUsesDomain(String(req.body.email), await getConfiguredEmailDomains())) { + res.status(400).json({ success: false, error: 'Diese E-Mail liegt auf einer verwalteten (Weiterleitungs-/Mailbox-)Domain und ist als private Kunden-E-Mail nicht zulässig.' } as ApiResponse); + return; + } if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) { res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); return; } const data: any = pickCustomerUpdate(req.body); + // E-Mail ist Pflichtfeld – darf per Update nicht geleert werden. + if ('email' in data && (!data.email || !String(data.email).trim())) { + res.status(400).json({ success: false, error: 'E-Mail-Adresse ist ein Pflichtfeld.' } as ApiResponse); + return; + } // Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur // stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile @@ -192,7 +214,8 @@ export async function updateCustomer(req: Request, res: Response): Promise data.birthDate = new Date(data.birthDate); } // Leere Strings in optionalen Feldern zu null konvertieren - const nullableFields = ['salutation', 'birthPlace', 'phone', 'mobile', 'email', 'companyName', 'taxNumber', 'businessRegistration', 'commercialRegister', 'commercialRegisterNumber', 'notes']; + // 'email' NICHT nullable – ist Pflichtfeld (leer wird oben schon abgelehnt). + const nullableFields = ['salutation', 'birthPlace', 'phone', 'mobile', 'companyName', 'taxNumber', 'businessRegistration', 'commercialRegister', 'commercialRegisterNumber', 'notes']; for (const field of nullableFields) { if (data[field] === '') data[field] = null; } diff --git a/backend/src/services/emailProvider/emailProviderService.ts b/backend/src/services/emailProvider/emailProviderService.ts index ac553f04..aa3de377 100644 --- a/backend/src/services/emailProvider/emailProviderService.ts +++ b/backend/src/services/emailProvider/emailProviderService.ts @@ -74,6 +74,32 @@ export async function getActiveProviderConfig() { }); } +/** + * Alle konfigurierten Provider-Domains (lowercase, dedupliziert). Das sind + * UNSERE verwalteten Mailbox-/Weiterleitungs-Domains (z.B. stressfrei-wechseln.de) + * – eine private Kunden-E-Mail darf darauf NICHT liegen, sonst trägt man + * versehentlich eine unserer Weiterleitungsadressen als private Adresse ein. + */ +export async function getConfiguredEmailDomains(): Promise { + const configs = await prisma.emailProviderConfig.findMany({ select: { domain: true } }); + const domains = configs + .map((c) => (c.domain || '').trim().toLowerCase()) + .filter((d) => d.length > 0); + return [...new Set(domains)]; +} + +/** + * true, wenn die E-Mail auf einer der übergebenen Domains (oder einer Subdomain + * davon) liegt. Vergleich case-insensitiv. + */ +export function emailUsesDomain(email: string, domains: string[]): boolean { + const at = email.lastIndexOf('@'); + if (at < 0) return false; + const host = email.slice(at + 1).trim().toLowerCase(); + if (!host) return false; + return domains.some((d) => host === d || host.endsWith('.' + d)); +} + export interface CreateProviderConfigData { name: string; type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN'; diff --git a/frontend/src/pages/customers/CustomerForm.tsx b/frontend/src/pages/customers/CustomerForm.tsx index a8b078ff..55c461ae 100644 --- a/frontend/src/pages/customers/CustomerForm.tsx +++ b/frontend/src/pages/customers/CustomerForm.tsx @@ -198,11 +198,17 @@ export default function CustomerForm() {
- +
+ +

+ Private E-Mail des Kunden – keine verwaltete Weiterleitungs-/Mailbox-Domain. +

+