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:
@@ -292,15 +292,21 @@ export async function deleteEmail(id: number) {
|
|||||||
*
|
*
|
||||||
* Pentest 71.4: DB-Update wird bei Provider-Sync-Fehler zurückgerollt,
|
* Pentest 71.4: DB-Update wird bei Provider-Sync-Fehler zurückgerollt,
|
||||||
* damit Plesk und DB nicht auseinanderlaufen.
|
* 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(
|
export async function setAdditionalForwards(
|
||||||
id: number,
|
id: number,
|
||||||
emails: string[],
|
emails: string[],
|
||||||
): Promise<{ success: boolean; forwardTargets?: string[]; error?: 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({
|
const meta = await prisma.stressfreiEmail.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: {
|
select: {
|
||||||
|
email: true,
|
||||||
additionalForwardingEmails: true,
|
additionalForwardingEmails: true,
|
||||||
customer: { select: { email: true } },
|
customer: { select: { email: true } },
|
||||||
},
|
},
|
||||||
@@ -312,6 +318,7 @@ export async function setAdditionalForwards(
|
|||||||
const customerEmailKey = meta.customer?.email
|
const customerEmailKey = meta.customer?.email
|
||||||
? canonicalEmailKey(meta.customer.email)
|
? canonicalEmailKey(meta.customer.email)
|
||||||
: null;
|
: null;
|
||||||
|
const selfKey = canonicalEmailKey(meta.email);
|
||||||
|
|
||||||
// Input normalisieren + Duplikate raus.
|
// Input normalisieren + Duplikate raus.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -320,6 +327,16 @@ export async function setAdditionalForwards(
|
|||||||
for (const raw of emails) {
|
for (const raw of emails) {
|
||||||
const ok = assertValidForwardingEmail(raw);
|
const ok = assertValidForwardingEmail(raw);
|
||||||
const key = canonicalEmailKey(ok);
|
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)) {
|
if (!seen.has(key)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
cleaned.push(ok);
|
cleaned.push(ok);
|
||||||
|
|||||||
@@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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**
|
- [x] **🔒 Pentest 77.3 (LOW): `requireIdParam` ließ Float-IDs durch**
|
||||||
- `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
|
- `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
|
||||||
den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
|
den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
|
||||||
|
|||||||
@@ -4241,6 +4241,7 @@ function StressfreiEmailModal({
|
|||||||
onClose={() => setShowForwardsModal(false)}
|
onClose={() => setShowForwardsModal(false)}
|
||||||
email={email ?? undefined}
|
email={email ?? undefined}
|
||||||
customerEmail={customerEmail}
|
customerEmail={customerEmail}
|
||||||
|
selfEmail={localPart ? localPart + domainSuffix : undefined}
|
||||||
value={additionalForwards}
|
value={additionalForwards}
|
||||||
onChange={setAdditionalForwards}
|
onChange={setAdditionalForwards}
|
||||||
/>
|
/>
|
||||||
@@ -4258,6 +4259,7 @@ function AdditionalForwardsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
email,
|
email,
|
||||||
customerEmail,
|
customerEmail,
|
||||||
|
selfEmail,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -4265,6 +4267,9 @@ function AdditionalForwardsModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
email?: StressfreiEmail;
|
email?: StressfreiEmail;
|
||||||
customerEmail?: string;
|
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. */
|
/** Aktuelle Liste – im Create-Modus controlled, im Edit-Modus initialer Wert. */
|
||||||
value: string[];
|
value: string[];
|
||||||
onChange: (next: string[]) => void;
|
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) => {
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const candidate = newEmail.trim().toLowerCase();
|
const candidate = newEmail.trim().toLowerCase();
|
||||||
@@ -4312,11 +4328,17 @@ function AdditionalForwardsModal({
|
|||||||
setError('Bitte eine gültige E-Mail-Adresse eingeben.');
|
setError('Bitte eine gültige E-Mail-Adresse eingeben.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (candidate === customerEmail?.toLowerCase()) {
|
const candidateKey = canonicalize(candidate);
|
||||||
|
if (customerEmail && candidateKey === canonicalize(customerEmail)) {
|
||||||
setError('Die Stamm-E-Mail des Kunden ist bereits Weiterleitungsziel.');
|
setError('Die Stamm-E-Mail des Kunden ist bereits Weiterleitungsziel.');
|
||||||
return;
|
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.');
|
setError('Diese Adresse ist schon in der Liste.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user