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
@@ -292,15 +292,21 @@ export async function deleteEmail(id: number) {
*
* Pentest 71.4: DB-Update wird bei Provider-Sync-Fehler zurückgerollt,
* damit Plesk und DB nicht auseinanderlaufen.
*
* Pentest 81.1: Self-Forward wird hart abgelehnt würde sonst am
* Provider einen Mail-Loop erzeugen (Stressfrei-Adresse leitet auf
* sich selbst um → unendliche Weiterleitung).
*/
export async function setAdditionalForwards(
id: number,
emails: string[],
): Promise<{ success: boolean; forwardTargets?: string[]; error?: string }> {
// Kunden-Stamm-Mail holen für Dedup gegen das (immer mit-gesetzte) Default-Ziel.
// Kunden-Stamm-Mail + eigene Email holen für Dedup gegen die fest
// gesetzten Ziele bzw. die Stressfrei-Adresse selbst.
const meta = await prisma.stressfreiEmail.findUnique({
where: { id },
select: {
email: true,
additionalForwardingEmails: true,
customer: { select: { email: true } },
},
@@ -312,6 +318,7 @@ export async function setAdditionalForwards(
const customerEmailKey = meta.customer?.email
? canonicalEmailKey(meta.customer.email)
: null;
const selfKey = canonicalEmailKey(meta.email);
// Input normalisieren + Duplikate raus.
const seen = new Set<string>();
@@ -320,6 +327,16 @@ export async function setAdditionalForwards(
for (const raw of emails) {
const ok = assertValidForwardingEmail(raw);
const key = canonicalEmailKey(ok);
// 81.1: Eintrag, der auf die Adresse selbst zeigt, würde einen
// Mail-Loop am Provider erzeugen. Hart ablehnen mit klarer
// Fehlermeldung, statt silent zu droppen der User soll merken,
// dass sein Eintrag bewusst nicht akzeptiert wurde.
if (key === selfKey) {
throw new ApiError(
400,
`"${ok}" zeigt auf die Adresse selbst Mail-Loop. Bitte eine andere Weiterleitungsadresse wählen.`,
);
}
if (!seen.has(key)) {
seen.add(key);
cleaned.push(ok);
+17
View File
@@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔒 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider**
- Bug: User konnte die Stressfrei-Adresse selbst (z.B.
`max.mustermann@stressfrei-wechseln.net`) als zusätzliches
Weiterleitungsziel eintragen auch Plus-Varianten davon. Plesk
leitet auf sich selbst um → Mail-Loop.
- Backend (`setAdditionalForwards`): zieht jetzt zusätzlich
`meta.email` aus der DB und vergleicht `canonicalEmailKey(eintrag)`
gegen `canonicalEmailKey(meta.email)`. Bei Treffer hartes
`ApiError(400)` mit klarer Self-Forward-Meldung statt silent dedup
der User soll merken, dass sein Eintrag bewusst abgelehnt wurde.
- Frontend (`AdditionalForwardsModal`): zusätzlich proaktive
Validierung im Sub-Modal mit identischem `canonicalize`-Helper
(Plus-Tag strippen, lowercase). Neuer Prop `selfEmail`, damit
auch der Create-Modus (vor dem Persistieren) den Check fahren
kann. Spart einen Roundtrip + zeigt sofort eine sprechende
Meldung „… zeigt auf die Adresse selbst Mail-Loop".
- [x] **🔒 Pentest 77.3 (LOW): `requireIdParam` ließ Float-IDs durch**
- `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
@@ -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;
}