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:
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as customerService from '../services/customer.service.js';
|
import * as customerService from '../services/customer.service.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { logChange } from '../services/audit.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 { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -99,12 +100,23 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
||||||
const data: any = pickCustomerCreate(req.body);
|
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
|
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
|
||||||
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
||||||
if (data.email && !isValidEmail(data.email)) {
|
if (data.email && !isValidEmail(data.email)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
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)) {
|
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
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);
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
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)) {
|
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: any = pickCustomerUpdate(req.body);
|
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
|
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
|
||||||
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
// 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);
|
data.birthDate = new Date(data.birthDate);
|
||||||
}
|
}
|
||||||
// Leere Strings in optionalen Feldern zu null konvertieren
|
// 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) {
|
for (const field of nullableFields) {
|
||||||
if (data[field] === '') data[field] = null;
|
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 {
|
export interface CreateProviderConfigData {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
||||||
|
|||||||
@@ -198,11 +198,17 @@ export default function CustomerForm() {
|
|||||||
|
|
||||||
<Card className="mb-6" title="Kontaktdaten">
|
<Card className="mb-6" title="Kontaktdaten">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<div>
|
||||||
label="E-Mail"
|
<Input
|
||||||
type="email"
|
label="E-Mail *"
|
||||||
{...register('email')}
|
type="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
|
<Input
|
||||||
label="Telefon"
|
label="Telefon"
|
||||||
|
|||||||
Reference in New Issue
Block a user