Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain erlaubt

Die private Kunden-E-Mail (Customer.email) darf nicht auf einer der bei den
E-Mail-Providern konfigurierten Domains (bzw. Subdomains) liegen - sonst
traegt man versehentlich eine unserer verwalteten Weiterleitungs-/Mailbox-
Adressen als private Adresse ein. Zudem ist E-Mail jetzt Pflichtfeld.

Backend: createCustomer/updateCustomer pruefen Pflicht + Domain (neue Helper
getConfiguredEmailDomains/emailUsesDomain im emailProvider-Service);
email aus nullableFields entfernt. Frontend: E-Mail-Feld required + Hinweis.

Verifiziert: Provider-Domain + Subdomain (case-insensitiv) verboten,
Fremd-Domains erlaubt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:08:12 +02:00
co-authored by Claude Opus 4.8
parent db4b9edd13
commit b8128616f2
3 changed files with 61 additions and 6 deletions
+24 -1
View File
@@ -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<void>
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<void>
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<void>
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;
}
@@ -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<string[]> {
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';
@@ -198,11 +198,17 @@ export default function CustomerForm() {
<Card className="mb-6" title="Kontaktdaten">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
label="E-Mail"
label="E-Mail *"
type="email"
{...register('email')}
{...register('email', { required: 'E-Mail ist erforderlich' })}
error={errors.email?.message}
/>
<p className="mt-1 text-xs text-gray-500">
Private E-Mail des Kunden keine verwaltete Weiterleitungs-/Mailbox-Domain.
</p>
</div>
<Input
label="Telefon"