Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider

Bug: Die Stressfrei-Adresse selbst (max@stressfrei-wechseln.net)
konnte als zusätzliches Weiterleitungsziel eingetragen werden,
auch Plus-Varianten. Plesk leitet auf sich selbst um → Mail-Loop.

Backend setAdditionalForwards: lädt zusätzlich meta.email, vergleicht
canonicalEmailKey gegen canonicalEmailKey(meta.email). Bei Treffer
hartes ApiError(400) mit klarer "zeigt auf die Adresse selbst –
Mail-Loop"-Meldung statt silent dedup – der User soll merken, dass
sein Eintrag bewusst abgelehnt wurde.

Frontend AdditionalForwardsModal: zusätzliche proaktive Validierung
im Sub-Modal mit identischem canonicalize-Helper. Neuer selfEmail-
Prop, damit auch der Create-Modus (vor Persist) den Check fahren
kann. Spart Roundtrip + sofort sprechende Meldung.
This commit is contained in:
2026-06-18 15:55:01 +02:00
parent b3469483ca
commit 5bb048c534
3 changed files with 59 additions and 3 deletions
@@ -4241,6 +4241,7 @@ function StressfreiEmailModal({
onClose={() => setShowForwardsModal(false)}
email={email ?? undefined}
customerEmail={customerEmail}
selfEmail={localPart ? localPart + domainSuffix : undefined}
value={additionalForwards}
onChange={setAdditionalForwards}
/>
@@ -4258,6 +4259,7 @@ function AdditionalForwardsModal({
onClose,
email,
customerEmail,
selfEmail,
value,
onChange,
}: {
@@ -4265,6 +4267,9 @@ function AdditionalForwardsModal({
onClose: () => void;
email?: StressfreiEmail;
customerEmail?: string;
/** Die Stressfrei-Adresse selbst (für Self-Forward-Check im Create-Modus,
* wo es noch kein `email`-Prop gibt). Edit-Modus zieht's aus `email`. */
selfEmail?: string;
/** Aktuelle Liste im Create-Modus controlled, im Edit-Modus initialer Wert. */
value: string[];
onChange: (next: string[]) => void;
@@ -4304,6 +4309,17 @@ function AdditionalForwardsModal({
}
};
// Plus-Tag wegstrippen + lowercase, identisch zum Backend-canonicalEmailKey.
// Dann landen `billing+x@y` und `billing@y` im selben Key.
const canonicalize = (raw: string) => {
const lower = raw.trim().toLowerCase();
const at = lower.lastIndexOf('@');
if (at < 1) return lower;
const local = lower.slice(0, at);
const plus = local.indexOf('+');
return (plus === -1 ? local : local.slice(0, plus)) + '@' + lower.slice(at + 1);
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
const candidate = newEmail.trim().toLowerCase();
@@ -4312,11 +4328,17 @@ function AdditionalForwardsModal({
setError('Bitte eine gültige E-Mail-Adresse eingeben.');
return;
}
if (candidate === customerEmail?.toLowerCase()) {
const candidateKey = canonicalize(candidate);
if (customerEmail && candidateKey === canonicalize(customerEmail)) {
setError('Die Stamm-E-Mail des Kunden ist bereits Weiterleitungsziel.');
return;
}
if (value.some((f) => f.toLowerCase() === candidate)) {
const ownAddress = email?.email ?? selfEmail;
if (ownAddress && candidateKey === canonicalize(ownAddress)) {
setError(`"${candidate}" zeigt auf die Adresse selbst das würde einen Mail-Loop erzeugen.`);
return;
}
if (value.some((f) => canonicalize(f) === candidateKey)) {
setError('Diese Adresse ist schon in der Liste.');
return;
}