diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index c3713527..c41d479f 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -22,6 +22,7 @@ import { canAccessCustomer, canAccessContract, canAccessCachedEmail, + canAccessStressfreiEmail, } from '../utils/accessControl.js'; // ==================== E-MAIL LIST ==================== @@ -214,9 +215,10 @@ export async function unassignFromContract(req: Request, res: Response): Promise } // E-Mail-Anzahl pro Ordner für ein Konto -export async function getFolderCounts(req: Request, res: Response): Promise { +export async function getFolderCounts(req: AuthRequest, res: Response): Promise { try { const stressfreiEmailId = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return; const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId); @@ -250,9 +252,10 @@ export async function getContractFolderCounts(req: Request, res: Response): Prom // ==================== SYNC & SEND ==================== // E-Mails für ein Konto synchronisieren (INBOX + SENT) -export async function syncAccount(req: Request, res: Response): Promise { +export async function syncAccount(req: AuthRequest, res: Response): Promise { try { const stressfreiEmailId = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return; const fullSync = req.query.full === 'true'; // Synchronisiert sowohl INBOX als auch SENT @@ -292,9 +295,10 @@ function hasCRLF(value: unknown): boolean { } // E-Mail senden -export async function sendEmailFromAccount(req: Request, res: Response): Promise { +export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise { try { const stressfreiEmailId = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return; const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body; // Header-Injection (CRLF) in Empfänger/Betreff ablehnen @@ -624,9 +628,10 @@ export async function getMailboxAccounts(req: Request, res: Response): Promise { +export async function enableMailbox(req: AuthRequest, res: Response): Promise { try { const id = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, id))) return; const result = await stressfreiEmailService.enableMailbox(id); @@ -649,9 +654,10 @@ export async function enableMailbox(req: Request, res: Response): Promise } // Mailbox-Status mit Provider synchronisieren -export async function syncMailboxStatus(req: Request, res: Response): Promise { +export async function syncMailboxStatus(req: AuthRequest, res: Response): Promise { try { const id = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, id))) return; const result = await stressfreiEmailService.syncMailboxStatus(id); @@ -697,9 +703,13 @@ export async function getThread(req: Request, res: Response): Promise { } // Mailbox-Zugangsdaten abrufen (IMAP/SMTP) -export async function getMailboxCredentials(req: Request, res: Response): Promise { +export async function getMailboxCredentials(req: AuthRequest, res: Response): Promise { try { const id = parseInt(req.params.id); + // Ownership-Check: ohne diesen Check konnte ein Portal-Kunde mit + // bekannter Stressfrei-Email-ID die kompletten IMAP/SMTP-Credentials + // eines anderen Kunden abrufen (IDOR). Pentest-Finding 2026-05-XX. + if (!(await canAccessStressfreiEmail(req, res, id))) return; // StressfreiEmail laden const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id); diff --git a/backend/src/controllers/stressfreiEmail.controller.ts b/backend/src/controllers/stressfreiEmail.controller.ts index 11b53188..a63d157f 100644 --- a/backend/src/controllers/stressfreiEmail.controller.ts +++ b/backend/src/controllers/stressfreiEmail.controller.ts @@ -68,9 +68,11 @@ export async function createEmail(req: Request, res: Response): Promise { } } -export async function updateEmail(req: Request, res: Response): Promise { +export async function updateEmail(req: AuthRequest, res: Response): Promise { try { - const email = await stressfreiEmailService.updateEmail(parseInt(req.params.id), req.body); + const emailId = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, emailId))) return; + const email = await stressfreiEmailService.updateEmail(emailId, req.body); await logChange({ req, action: 'UPDATE', resourceType: 'StressfreiEmail', resourceId: email.id.toString(), @@ -85,9 +87,10 @@ export async function updateEmail(req: Request, res: Response): Promise { } } -export async function deleteEmail(req: Request, res: Response): Promise { +export async function deleteEmail(req: AuthRequest, res: Response): Promise { try { const emailId = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, emailId))) return; await stressfreiEmailService.deleteEmail(emailId); await logChange({ req, action: 'DELETE', resourceType: 'StressfreiEmail', @@ -142,9 +145,11 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise { +export async function resetPassword(req: AuthRequest, res: Response): Promise { try { - const result = await stressfreiEmailService.resetMailboxPassword(parseInt(req.params.id)); + const emailId = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, emailId))) return; + const result = await stressfreiEmailService.resetMailboxPassword(emailId); if (!result.success) { res.status(400).json({ success: false, diff --git a/docs/SECURITY-HARDENING.md b/docs/SECURITY-HARDENING.md index f629a6b0..dbc8e7de 100644 --- a/docs/SECURITY-HARDENING.md +++ b/docs/SECURITY-HARDENING.md @@ -271,6 +271,65 @@ gering, aber Audit-Bewertung fordert konsistente Header-Hygiene. nachvollziehbar, wer wann welches Passwort eingesehen hat (DSGVO + Insider-Threat). +### Runde 13 – KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Live-Pentest-Fund) + +Externer Pentest hat einen echten Credential-Exfiltration-Angriff erfolgreich +durchgespielt: **als Portal-User von Kunde A komplette IMAP/SMTP-Klartext- +Credentials von Kunde B abgreifen können**. + +**Angriffspfad:** +1. Portal-Login als Kunde A +2. `/api/stressfrei-emails/{id}` GET unterschied saubere Antworten: + - „E-Mail-Konto nicht gefunden" (ID existiert nicht) + - „Kein Zugriff auf diese Kundendaten" (ID existiert, gehört anderem) + → Information-Disclosure: Existenz von IDs durchprobierbar +3. `/api/stressfrei-emails/{id}/credentials` GET ohne Ownership-Check → + IMAP/SMTP-Server, Username und **Klartext-Passwort** der fremden Mailbox + +**Root Cause:** der Haupt-Endpoint `GET /:id` hatte `canAccessStressfreiEmail`, +die 8 Sub-Endpoints unter `:id/*` hatten **alle keinen** Ownership-Check — +nur `authenticate + requirePermission('customers:read')`, was jeder Portal-User +hat. + +**Betroffene Endpoints (alle gefixt):** +- `GET /:id/credentials` ← **der kritische** (Klartext-Passwort + IMAP/SMTP) +- `GET /:id/folder-counts` +- `POST /:id/sync` +- `POST /:id/send` +- `POST /:id/enable-mailbox` +- `POST /:id/sync-mailbox-status` +- `POST /:id/reset-password` +- `PUT /:id` (updateEmail im stressfreiEmail.controller) +- `DELETE /:id` (deleteEmail) + +`canAccessStressfreiEmail(req, res, emailId)` als erste Zeile in jedem +Controller. `canAccessResourceByCustomerId` emittiert bei Fehlversuch +automatisch ein `ACCESS_DENIED MEDIUM`-Event ins Security-Monitoring → bei +>5 Versuchen in 5 min wird ein `CRITICAL SUSPICIOUS`-Event erzeugt + Alert +verschickt. + +**Live-verifiziert (Portal-User Kunde A versucht Email-ID von Kunde B):** + +| Endpoint | Vorher | Nachher | +| --- | --- | --- | +| `GET /:id/credentials` | 🚨 **200 mit Klartext-Passwort** | ✅ 403 | +| `GET /:id/folder-counts` | 🚨 200 | ✅ 403 | +| `POST /:id/sync` | 🚨 200 | ✅ 403 | +| `POST /:id/send` | 🚨 fremde Mailbox zum Versand missbrauchbar | ✅ 403 | +| `POST /:id/enable-mailbox` | 🚨 200 | ✅ 403 | +| `POST /:id/sync-mailbox-status` | 🚨 200 | ✅ 403 | +| `POST /:id/reset-password` | 🚨 fremdes Mailbox-Passwort zurücksetzbar | ✅ 403 | +| `POST /:id/sync-forwarding` | (vorher schon gefixt) | ✅ 403 | +| `PUT /:id` | 🚨 fremde Adresse änderbar | ✅ 403 | +| `DELETE /:id` | 🚨 fremde Adresse löschbar | ✅ 403 | +| Eigene Email-ID | (legitim) | ✅ 200/400 (durch) | +| Security-Monitor | – | 8× `ACCESS_DENIED MEDIUM` geloggt ✅ | + +**Lehre:** wenn ein Haupt-Endpoint `:id` einen Ownership-Check hat, müssen +**alle** Sub-Endpoints unter `:id/*` denselben Check haben. Eine fehlende +Zeile am Anfang eines Sub-Controllers reicht für komplette Credential- +Exfiltration über das Customer-Portal. + ### Runde 12 – JWT raus aus localStorage (XSS-Resistenz) Externer Pentest: "JWT in `localStorage` (MITTEL)". Bei einer XSS-Lücke diff --git a/docs/todo.md b/docs/todo.md index 97598c24..ca953562 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,29 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🚨 KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Pentest-Fund)** + - **Realer Angriff erfolgreich durchgespielt**: Portal-User konnte über + `/api/stressfrei-emails/{id}/credentials` die kompletten Klartext- + IMAP/SMTP-Zugangsdaten der Mailbox eines anderen Kunden abrufen. + - **Root Cause**: der Haupt-Endpoint `GET /:id` hatte + `canAccessStressfreiEmail`-Check, die **8 Sub-Endpoints** unter + `:id/*` hatten alle KEINEN Ownership-Check (nur `authenticate + + requirePermission('customers:read')`, was Portal-User von Haus aus + haben). + - **Fix**: `canAccessStressfreiEmail(req, res, id)` als erste Zeile in + allen 9 betroffenen Controllern: `getMailboxCredentials`, + `getFolderCounts`, `syncAccount`, `sendEmailFromAccount`, + `enableMailbox`, `syncMailboxStatus`, `resetPassword`, `updateEmail`, + `deleteEmail`. + - **Security-Monitor**: `canAccessResourceByCustomerId` emittiert + bei jedem Fehlversuch automatisch ein `ACCESS_DENIED MEDIUM`-Event + → Threshold-Detection (>5 in 5 min) erzeugt `CRITICAL SUSPICIOUS` + + Sofort-Alert. + - **Live-verifiziert**: Portal-User Kunde A probiert Email-ID von + Kunde B durch alle 8 Sub-Routes → **alle 8× HTTP 403**, eigene + Email-ID kommt sauber durch (200/400), 8× `ACCESS_DENIED`-Events + im Security-Monitor. + - [x] **🛡️ JWT-Tokens raus aus localStorage – Refresh-Cookie-Pattern** - Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS könnte JS den Token klauen + alle Anbieter-Credentials abrufen. Lösung: