Pentest 71.1-71.4: Härtung der Zusatz-Weiterleitungen
71.1 MEDIUM: BLOCKED_TLDS-Set in assertValidForwardingEmail – reservierte/private TLDs (local, internal, corp, lan, home, private, invalid, test, localhost, example, intranet, localdomain, arpa) werden abgelehnt. Schließt Plesk-DNS-Probing ins interne Netz. 71.2 LOW: canonicalEmailKey-Helper normalisiert Mail-Adressen für den Dedup (Plus-Tag wegstrippen, lowercase). billing+x@y und billing@y haben jetzt denselben Schlüssel – auch gegen Kunden- Stamm-Mail und gegen config.defaultForwardEmail im sync-Pfad. 71.3 INFO: Neuer requireIdParam-Helper im Controller liefert 400 statt 500 bei nicht-numerischen Route-IDs. Alle acht parseInt- Stellen umgestellt (auch über die gemeldete eine hinaus). 71.4 INFO: setAdditionalForwards rollt den DB-Stand zurück, wenn syncForwardingForEmail mit dem Provider scheitert. Vorheriger Wert wird vorm Update gemerkt und im Fehlerfall wieder eingespielt – DB und Plesk laufen nicht mehr auseinander. Smoke-Tests: 11 reservierte TLDs abgelehnt, 4 echte TLDs (de, com, co.uk, museum) durchgewinkt, Plus-Tag-Strip mit Multi-Plus+Casing.
This commit is contained in:
@@ -5,9 +5,23 @@ import { ApiResponse, AuthRequest } from '../types/index.js';
|
|||||||
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
|
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
|
||||||
import { ApiError } from '../utils/apiError.js';
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
// Pentest 71.3 (INFO): `parseInt(...)` ohne NaN-Check gab bei
|
||||||
|
// `/stressfrei-emails/abc/...` einen generischen 500 zurück. Nicht
|
||||||
|
// kritisch, aber irreführend und log-spammend.
|
||||||
|
function requireIdParam(req: AuthRequest, res: Response, paramName: string): number | null {
|
||||||
|
const raw = req.params[paramName];
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||||
|
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = requireIdParam(req, res, 'customerId');
|
||||||
|
if (customerId === null) return;
|
||||||
// requireCustomerAccess in der Route greift nicht ausreichend:
|
// requireCustomerAccess in der Route greift nicht ausreichend:
|
||||||
// Portal-User haben `customers:read` (für eigene Daten) und werden
|
// Portal-User haben `customers:read` (für eigene Daten) und werden
|
||||||
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
|
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
|
||||||
@@ -27,7 +41,8 @@ export async function getEmailsByCustomer(req: AuthRequest, res: Response): Prom
|
|||||||
|
|
||||||
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const email = await stressfreiEmailService.getEmailById(emailId);
|
const email = await stressfreiEmailService.getEmailById(emailId);
|
||||||
@@ -55,7 +70,8 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function createEmail(req: Request, res: Response): Promise<void> {
|
export async function createEmail(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = requireIdParam(req, res, 'customerId');
|
||||||
|
if (customerId === null) return;
|
||||||
const email = await stressfreiEmailService.createEmail({
|
const email = await stressfreiEmailService.createEmail({
|
||||||
...req.body,
|
...req.body,
|
||||||
customerId,
|
customerId,
|
||||||
@@ -77,7 +93,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -96,7 +113,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
await stressfreiEmailService.deleteEmail(emailId);
|
await stressfreiEmailService.deleteEmail(emailId);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -115,7 +133,8 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
|
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
|
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
|
||||||
@@ -159,7 +178,8 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
|
|||||||
*/
|
*/
|
||||||
export async function updateAdditionalForwards(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateAdditionalForwards(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const body = req.body ?? {};
|
const body = req.body ?? {};
|
||||||
@@ -202,7 +222,8 @@ export async function updateAdditionalForwards(req: AuthRequest, res: Response):
|
|||||||
|
|
||||||
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
|
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ import { ApiError } from '../utils/apiError.js';
|
|||||||
// Komma). Wirklich validiert wird vom Provider beim Sync.
|
// Komma). Wirklich validiert wird vom Provider beim Sync.
|
||||||
const FORWARD_EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
const FORWARD_EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
||||||
|
|
||||||
|
// Pentest 71.1 (MEDIUM, 2026-06-08): RFC-reservierte und private/
|
||||||
|
// On-Prem-TLDs. Eine Weiterleitung an `attacker@plesk.internal` würde
|
||||||
|
// am Provider DNS-Lookups gegen internes Netz auslösen oder bei mDNS-
|
||||||
|
// Setup an einen lokalen Mailserver gehen. Wir blocken sie hart.
|
||||||
|
const BLOCKED_TLDS = new Set([
|
||||||
|
'local', 'internal', 'corp', 'lan', 'home', 'private',
|
||||||
|
'invalid', 'test', 'localhost', 'example',
|
||||||
|
'intranet', 'localdomain', 'arpa',
|
||||||
|
]);
|
||||||
|
|
||||||
export function parseAdditionalForwards(raw: string | null | undefined): string[] {
|
export function parseAdditionalForwards(raw: string | null | undefined): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
try {
|
try {
|
||||||
@@ -33,6 +43,23 @@ export function serializeAdditionalForwards(list: string[]): string | null {
|
|||||||
return cleaned.length === 0 ? null : JSON.stringify(cleaned);
|
return cleaned.length === 0 ? null : JSON.stringify(cleaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine normalisierte Form der Adresse für den Dedup-Vergleich:
|
||||||
|
* lowercase + Plus-Tag aus dem Local-Part rausgestrippt.
|
||||||
|
* `billing+pentest@test.de` und `billing@test.de` haben so denselben
|
||||||
|
* Schlüssel und treffen sich beim Dedup. Pentest 71.2.
|
||||||
|
*/
|
||||||
|
export function canonicalEmailKey(email: string): string {
|
||||||
|
const trimmed = email.trim().toLowerCase();
|
||||||
|
const at = trimmed.lastIndexOf('@');
|
||||||
|
if (at < 1) return trimmed;
|
||||||
|
const localPart = trimmed.slice(0, at);
|
||||||
|
const domain = trimmed.slice(at + 1);
|
||||||
|
const plus = localPart.indexOf('+');
|
||||||
|
const cleanedLocal = plus === -1 ? localPart : localPart.slice(0, plus);
|
||||||
|
return `${cleanedLocal}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function assertValidForwardingEmail(value: unknown): string {
|
export function assertValidForwardingEmail(value: unknown): string {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new ApiError(400, 'Ungültige Weiterleitungs-E-Mail-Adresse');
|
throw new ApiError(400, 'Ungültige Weiterleitungs-E-Mail-Adresse');
|
||||||
@@ -44,6 +71,13 @@ export function assertValidForwardingEmail(value: unknown): string {
|
|||||||
if (!FORWARD_EMAIL_REGEX.test(trimmed)) {
|
if (!FORWARD_EMAIL_REGEX.test(trimmed)) {
|
||||||
throw new ApiError(400, `Ungültiges E-Mail-Format: ${trimmed}`);
|
throw new ApiError(400, `Ungültiges E-Mail-Format: ${trimmed}`);
|
||||||
}
|
}
|
||||||
|
// 71.1: TLD aus dem Domain-Part rausziehen und gegen die Blocklist
|
||||||
|
// halten. Domain liegt nach dem letzten @, TLD nach dem letzten Punkt.
|
||||||
|
const domain = trimmed.slice(trimmed.lastIndexOf('@') + 1).toLowerCase();
|
||||||
|
const tld = domain.slice(domain.lastIndexOf('.') + 1);
|
||||||
|
if (BLOCKED_TLDS.has(tld)) {
|
||||||
|
throw new ApiError(400, `Top-Level-Domain "${tld}" ist nicht erlaubt (reservierte/private TLD).`);
|
||||||
|
}
|
||||||
return trimmed.toLowerCase();
|
return trimmed.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,30 +236,73 @@ export async function deleteEmail(id: number) {
|
|||||||
* Komplette Liste zusätzlicher Weiterleitungs-E-Mails ersetzen und
|
* Komplette Liste zusätzlicher Weiterleitungs-E-Mails ersetzen und
|
||||||
* direkt mit dem Provider synchronisieren. Aufrufer hat eine canonical
|
* direkt mit dem Provider synchronisieren. Aufrufer hat eine canonical
|
||||||
* Liste – das Sub-Modal arbeitet auf Snapshot-Basis.
|
* Liste – das Sub-Modal arbeitet auf Snapshot-Basis.
|
||||||
|
*
|
||||||
|
* Pentest 71.2: Dedup über `canonicalEmailKey` (Plus-Tags strippen),
|
||||||
|
* damit `billing+tag@x.de` und `billing@x.de` als gleiches Ziel
|
||||||
|
* erkannt werden – auch im Vergleich zur Stamm-E-Mail des Kunden.
|
||||||
|
*
|
||||||
|
* Pentest 71.4: DB-Update wird bei Provider-Sync-Fehler zurückgerollt,
|
||||||
|
* damit Plesk und DB nicht auseinanderlaufen.
|
||||||
*/
|
*/
|
||||||
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 }> {
|
||||||
// Input normalisieren + Duplikate raus (case-insensitive).
|
// Kunden-Stamm-Mail holen für Dedup gegen das (immer mit-gesetzte) Default-Ziel.
|
||||||
|
const meta = await prisma.stressfreiEmail.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
additionalForwardingEmails: true,
|
||||||
|
customer: { select: { email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!meta) {
|
||||||
|
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
|
||||||
|
}
|
||||||
|
const previousRaw = meta.additionalForwardingEmails;
|
||||||
|
const customerEmailKey = meta.customer?.email
|
||||||
|
? canonicalEmailKey(meta.customer.email)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Input normalisieren + Duplikate raus.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
if (customerEmailKey) seen.add(customerEmailKey);
|
||||||
const cleaned: string[] = [];
|
const cleaned: string[] = [];
|
||||||
for (const raw of emails) {
|
for (const raw of emails) {
|
||||||
const ok = assertValidForwardingEmail(raw);
|
const ok = assertValidForwardingEmail(raw);
|
||||||
if (!seen.has(ok)) {
|
const key = canonicalEmailKey(ok);
|
||||||
seen.add(ok);
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
cleaned.push(ok);
|
cleaned.push(ok);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextRaw = serializeAdditionalForwards(cleaned);
|
||||||
await prisma.stressfreiEmail.update({
|
await prisma.stressfreiEmail.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { additionalForwardingEmails: serializeAdditionalForwards(cleaned) },
|
data: { additionalForwardingEmails: nextRaw },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Provider unmittelbar nachziehen, sonst läuft das Plesk-Mail-Konto
|
// Provider unmittelbar nachziehen, sonst läuft das Plesk-Mail-Konto
|
||||||
// mit der alten Liste weiter.
|
// mit der alten Liste weiter.
|
||||||
return syncForwardingForEmail(id);
|
const syncResult = await syncForwardingForEmail(id);
|
||||||
|
|
||||||
|
// 71.4: Rollback wenn Plesk den Sync abgelehnt hat. DB darf nicht
|
||||||
|
// den optimistischen Stand zeigen, wenn der Provider noch auf dem
|
||||||
|
// alten Stand ist.
|
||||||
|
if (!syncResult.success && previousRaw !== nextRaw) {
|
||||||
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { additionalForwardingEmails: previousRaw },
|
||||||
|
}).catch((rollbackErr) => {
|
||||||
|
console.error(
|
||||||
|
'[setAdditionalForwards] Rollback nach Provider-Fail fehlgeschlagen:',
|
||||||
|
rollbackErr,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
|
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
|
||||||
@@ -401,9 +478,14 @@ export async function syncForwardingForEmail(
|
|||||||
}
|
}
|
||||||
// Zusätzliche Weiterleitungsziele (vom User im Modal gepflegt). Duplikate
|
// Zusätzliche Weiterleitungsziele (vom User im Modal gepflegt). Duplikate
|
||||||
// gegen die Stamm-Mail oder den Default werden hier weggefiltert, damit
|
// gegen die Stamm-Mail oder den Default werden hier weggefiltert, damit
|
||||||
// Plesk nicht mit Wiederholungen die Liste aufbläht.
|
// Plesk nicht mit Wiederholungen die Liste aufbläht. Pentest 71.2:
|
||||||
|
// Vergleich über `canonicalEmailKey`, damit Plus-Tags nicht doppelt
|
||||||
|
// zustellen.
|
||||||
|
const seenKeys = new Set(forwardTargets.map(canonicalEmailKey));
|
||||||
for (const extra of parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails)) {
|
for (const extra of parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails)) {
|
||||||
if (!forwardTargets.some((t) => t.toLowerCase() === extra.toLowerCase())) {
|
const key = canonicalEmailKey(extra);
|
||||||
|
if (!seenKeys.has(key)) {
|
||||||
|
seenKeys.add(key);
|
||||||
forwardTargets.push(extra);
|
forwardTargets.push(extra);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,29 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 71.1–71.4: Härtung der Zusatz-Weiterleitungen**
|
||||||
|
- **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`,
|
||||||
|
`corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`,
|
||||||
|
`example`, `intranet`, `localdomain`, `arpa`) werden in
|
||||||
|
`assertValidForwardingEmail` jetzt hart abgelehnt. Verhindert
|
||||||
|
Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups.
|
||||||
|
- **71.2 LOW:** Neuer Helper `canonicalEmailKey` normalisiert Mail-
|
||||||
|
Adressen für den Dedup-Vergleich (Plus-Tag wegstrippen,
|
||||||
|
lowercase). `billing+pentest@x.de` und `billing@x.de` werden als
|
||||||
|
dasselbe Ziel erkannt – auch im Vergleich zur Kunden-Stamm-Mail
|
||||||
|
und im sync-Pfad gegen `config.defaultForwardEmail`.
|
||||||
|
- **71.3 INFO:** Neuer `requireIdParam(req, res, paramName)`-Helper
|
||||||
|
fängt nicht-numerische Route-Parameter und liefert 400 statt 500.
|
||||||
|
Alle acht parseInt-Stellen in `stressfreiEmail.controller.ts`
|
||||||
|
umgestellt (auch über das gemeldete Finding hinaus).
|
||||||
|
- **71.4 INFO:** `setAdditionalForwards` rollt den DB-Stand bei
|
||||||
|
Provider-Sync-Fehler zurück, damit DB und Plesk nicht
|
||||||
|
auseinanderlaufen. Vorheriger `additionalForwardingEmails`-Wert
|
||||||
|
wird vor dem Update gemerkt und bei Fail wieder eingespielt.
|
||||||
|
- Smoke-Tests bestätigen: 11 reservierte TLDs abgelehnt, 4 echte
|
||||||
|
TLDs (`de`, `com`, `co.uk`, `museum`) durchgewinkt, Plus-Tag-
|
||||||
|
Strip funktioniert (auch mit Multi-Plus + Casing).
|
||||||
|
|
||||||
- [x] **🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen**
|
- [x] **🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen**
|
||||||
- Im „Adresse hinzufügen"-Modal erscheint der „Weitere
|
- Im „Adresse hinzufügen"-Modal erscheint der „Weitere
|
||||||
Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
|
Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
|
||||||
|
|||||||
Reference in New Issue
Block a user