From 5bb048c534257f76baec72793eea3aebad842783 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 18 Jun 2026 15:55:01 +0200 Subject: [PATCH] Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/services/stressfreiEmail.service.ts | 19 +++++++++++++- docs/todo.md | 17 ++++++++++++ .../src/pages/customers/CustomerDetail.tsx | 26 +++++++++++++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/backend/src/services/stressfreiEmail.service.ts b/backend/src/services/stressfreiEmail.service.ts index 56234ff1..420f6dab 100644 --- a/backend/src/services/stressfreiEmail.service.ts +++ b/backend/src/services/stressfreiEmail.service.ts @@ -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(); @@ -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); diff --git a/docs/todo.md b/docs/todo.md index 2f8cb380..b012e8ff 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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/...` diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index a498e74f..43877ee5 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -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; }