Compare commits

...

58 Commits

Author SHA1 Message Date
duffyduck b23ebeefc3 Vertrag-UI: Kunde-Link + Externtab + Info-Modal überall einheitlich
ContractDetail (Vertragsansicht): neben dem Kunden-Link sitzt jetzt
zusätzlich ein ExternalLink-Icon, das die Kundenakte in einem neuen
Tab öffnet. Info-Icon (Schnellansicht-Modal) bleibt wie gehabt.

ContractForm (Neuer/Bearbeiten-Vertrag): bekommt unter dem Heading
dieselbe Kunden-Zeile wie ContractDetail – Customer-Name als Link,
ExternalLink-Icon für neuen Tab, Info-Icon für die
CustomerInfoModal-Schnellansicht. Nur sichtbar wenn schon ein
Kunde gewählt ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 21:03:20 +02:00
duffyduck 818f801939 Pentest R101.1: Inline-Preview-Pfad refaktoriert + Diagnose-Log
R101.1 INFO/funktional: Pentester sieht Content-Disposition:
attachment auch bei ?disposition=inline. Die Logik im Controller
ist korrekt und liefert beim Direkttest gegen echte PDFs
application/pdf, der Pfad lässt sich aber in Prod nicht
reproduzieren.

Refaktoriert:
- Magic-Byte-Check in detectSafeContentType() extrahiert
- File-Descriptor wird in finally garantiert geschlossen
- Short-Read-Fälle (bytesRead < n) explizit geguardet
- console.warn wenn inline angefragt aber Magic-Byte-Mismatch
  oder Read-Crash – damit der Fall in Prod-Logs sichtbar wird
  falls er wieder auftritt

Sicherheits-Verhalten unverändert: Mismatch → attachment
(Stored-XSS-Schutz aus R30.13).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 20:54:16 +02:00
duffyduck 386d206ff1 Vertragsdokumente-Modal: Vorschau-Link pro Dokument
Neben jeder Dokument-Zeile sitzt jetzt ein "Vorschau"-Link mit
ExternalLink-Icon, der die PDF in einem neuen Tab öffnet (via
viewUrl mit Token-Auth, inline-disposition). Klick darauf
schaltet bewusst NICHT die Checkbox um – die Auswahl bleibt,
nur das Dokument geht in einem zweiten Tab auf.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 15:56:16 +02:00
duffyduck 67d6fd4941 E-Mail-Liste: eigener Scrollbalken statt seitenweit wachsen
User-Bug: bei vielen E-Mails wuchs die Liste links unbegrenzt nach
unten, sodass die ganze Seite gescrollt werden musste.

- ContractEmailsSection: flex-Container von minHeight:400 auf
  feste 600px Höhe gestellt. Die linke Liste hatte schon
  overflow-y-auto – jetzt greift's auch.
- EmailClientTab: h-full auf calc(100vh - 240px) (mit
  minHeight:500) bounded. h-full hat im Tab-Container vorher
  nichts gebracht, weil der Parent selbst keine feste Höhe
  hatte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 07:51:16 +02:00
duffyduck 1680dcb0fe Pentest R97: Attachment-Validierung im Send-Handler
R97.1 LOW: malformed content (null, fehlend, true, "") landete
mit rohem Buffer.from()-Fehlertext in der Response; "" liess
sogar 0-Byte-Anhänge durch.
R97.2 INFO: keine App-Level-Caps für Größe/Anzahl – die im
Frontend dokumentierten 10/25 MB hingen am bodyParser.

Fix: validateAttachments() läuft VOR sendEmail() im Controller:
- max 25 Anhänge
- filename non-empty String, content non-empty Base64, optionaler
  contentType als String
- 10 MB pro Datei, 25 MB total (Größen-Schätzung über base64-Länge,
  kein Buffer.from während Validierung)

Harte 400 mit klarer Meldung. Sanity-Test 18/18 grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 00:45:59 +02:00
duffyduck a4895374b9 Kundendaten-Modal: nur Anbieter-Nummern, keine internen CRM-Nummern
Modal ist für Mails AN den Anbieter gedacht – interne CRM-Nummern
interessieren dort niemanden.

- formatCustomerBlock: customer.customerNumber (intern) raus,
  stattdessen contract.customerNumberAtProvider rein.
- formatContractBlock: interne contractNumber raus, restliche
  Anbieter-/Vertriebsplattform-Nummern bleiben.
- Previews ziehen ebenfalls auf customerNumberAtProvider /
  contractNumberAtProvider um, mit Hinweis-Text wenn keine
  Anbieter-Nummer hinterlegt ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 16:10:20 +02:00
duffyduck ebaee024b6 Kundendaten-Modal: Bank + Ausweis getrennte Text/PDF-Wahl
Bank- und Ausweis-Section haben jetzt jeweils zwei unabhängige
Checkboxen statt der bisherigen Section-Checkbox + Sub-Attach:

- Bank: "Letzte 4 IBAN-Stellen einfügen" + "Bankkarte als PDF
  anhängen". Text-Variante zeigt nur "IBAN endet auf: XXXX" – keine
  volle IBAN/BIC/Bank-Liste mehr (Mail-Hygiene).
- Ausweis: "{Typ}-Nummer einfügen" + "{Typ} als PDF anhängen".
  Text-Variante zeigt nur die Nummer, keine Behörde/Daten.

Alle drei Kombinationen "nur Text", "nur PDF" und "beides" sind
damit möglich, "keins von beidem" entspricht der Section-aus.
Schalter sind disabled wenn der jeweilige Wert (IBAN /
documentNumber / documentPath) nicht vorhanden ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 16:08:35 +02:00
duffyduck f1b05c56e5 Kundendaten-Modal: E-Mail-Wahl Stammdaten vs. Absender
In der "Anrede & Name"-Section neue Radio-Wahl, sobald die
Section aktiv ist:
- Stammdaten-E-Mail (customer.email) – default wenn vorhanden
- Absender-Adresse (Postfach von dem gesendet wird)
- Keine E-Mail einfügen

Wird in den Customer-Block-Builder durchgereicht und ersetzt die
fix verdrahtete customer.email-Zeile. Wenn die Stammdaten-Mail
fehlt, ist der Radio "Stammdaten" disabled und der Default
springt auf "Absender".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 16:04:54 +02:00
duffyduck 5293af18a5 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen
Zwei neue Buttons im Compose-Modal (nur sichtbar bei Vertrag-
Kontext):

- Vertragsdokumente: listet alle am Vertrag gespeicherten
  ContractDocuments gruppiert nach documentType. Auswahl →
  Token-Download via fileUrl → base64 → Anhang.
- Kundendaten einfügen: zeigt Sections nur wenn Daten vorhanden
  (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag, Bank,
  Ausweis). Bei Bank/Ausweis zusätzlich Sub-Checkbox "als PDF
  anhängen" wenn documentPath vorhanden. Text-Blöcke ans Body-
  Ende, PDFs in attachments[]. 25-MB-Limit beidseitig geprüft.

Helpers in composeAttachmentHelpers.ts:
- serverFileToAttachment(path, filename) für Token-URL→Blob→base64
- totalAttachmentBytes mit ~33% base64-Overhead
- sprechende Dateinamen via bankCardAttachmentName /
  identityDocAttachmentName

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 15:55:13 +02:00
duffyduck 4ab0340473 Pentest R95: portalUsername (Manual-Modus) härten
R95.1 MEDIUM: foo\r\nBcc:evil@x.de → Header-Injection-Vektor
R95.3 LOW: <script>...</script>@x.de → silent stripHtml-Mutation
R95.4 LOW: >190 Zeichen → VARCHAR-Overflow → 500 statt 400

Fix: validatePortalUsername() in sanitize.ts mit Whitelist
^[A-Za-z0-9_\-/.@+ ]{0,100}$. Strukturell sind CRLF, Tab, alle
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
Check. Max 100 → ApiError(400) → R95.4. Raw-Input vor stripHtml
geprüft (R87-Pattern). Eingehängt in sanitizeContractBody.

R95.2 (Email-Format-Pflicht) bewusst NICHT übernommen:
portalUsername ist im Manual-Modus nicht zwingend eine Email
(Vodafone, 1&1, EWE und Stadtwerke nutzen Kundennummern oder
Pseudonyme als Portal-Login). Doku in SECURITY-HARDENING.md
§ Runde 95.

Frontend: maxLength={100} am Input als UX-Schicht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 15:24:57 +02:00
duffyduck f1102a24b7 CopyButton: Portal-Benutzername + E-Mail-Postfach-Selector
ContractForm: neben dem "Portal Benutzername"-Label sitzt jetzt
ein CopyButton, der je nach Modus den manuellen Eingabewert oder
die ausgewählte Stressfrei-Adresse in die Zwischenablage kopiert.
Erscheint nur wenn der jeweilige Wert nicht leer ist.

EmailClientTab + ContractEmailsSection: rechts neben dem Account-
Selector liegt jetzt ein CopyButton, der die Postfach-Adresse des
aktuell gewählten Mailbox-Kontos kopiert. Funktioniert in beiden
UI-Varianten (Single-Account-Span und Multi-Account-Dropdown).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 15:05:25 +02:00
duffyduck c013e1e747 Pentest R93: Leerer String != fehlender Query-Param
R93.1 INFO: ?accountId= (explizit-leer) wurde wie ?accountId
weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.
Pentester-Spec: leerer String ist keine gültige Zahl.

Fix in parsePositiveIntQuery: nur `v === undefined` ist absent;
'', '  ', alles andere muss parsen. Required + optional Modes
unverändert. Sanity-Test: alle 11 Cases grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 14:54:15 +02:00
duffyduck caa283e66f Pentest R92: Strict-400 für accountId auf Vertrags-Endpunkten
R91-Fix war silent-undefined bei invaliden Werten – accountId=abc
auf Vertrags-Endpunkten brach die Mailbox-Isolation (Mails aus
allen Postfächern statt 400). Pentester R92 hat zu Recht
Strict-400 vorgeschlagen.

Helper parsePositiveIntQuery() bekommt { required } option:
- optional (default): fehlend → undefined (kein Filter), invalid → 400
- required: fehlend ODER invalid → 400

Vertrags-Endpunkte (Emails + Folder-Counts) auf required gestellt.
Customer-/Trash-Endpunkte bleiben optional (Cross-Mailbox-View ist
legitim), aber invalid → 400. Frontend hat eh enabled-Guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 14:47:42 +02:00
duffyduck 18a2e1173b Pentest R91: NaN-Bypass auf accountId-Query-Param
R91.1 LOW: parseInt('abc') = NaN → der Ternary gab NaN an den
Service, if (NaN) ist falsy → Postfach-Filter fiel weg. Portal-
User mit ungültigem accountId sah Mails aus allen Postfächern
des Kunden für seinen Vertrag (canAccessContract greift weiter,
kein Cross-Customer-Leak).

Fix: zentraler parsePositiveIntParam(), akzeptiert nur positive
Ganzzahlen aus Query-Strings. Eingesetzt auf allen 5 Endpunkten,
die accountId/contractId aus Query lesen – auch da, wo der
Pentester nicht getestet hat (Customer-Inbox, Trash-Count),
weil derselbe Pattern überall stand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 14:22:40 +02:00
duffyduck 993f2d10f0 E-Mail-Ansicht: Postfach-Filter in Trash/Sent durchreichen
Bug: Im Vertrags-Tab (Gesendet/Gelöscht) und im Kunden-Haupt-
Postfach (Gelöscht) wurden Mails aus ALLEN Postfächern angezeigt,
unabhängig vom ausgewählten Postfach. Im Vertrag fehlte zusätzlich
der Vertrags-Filter im Papierkorb.

Backend:
- getEmailsForContract akzeptiert accountId → stressfreiEmailId
- getTrashEmails (controller + service) nimmt {accountId, contractId}
- getFolderCountsForContract bekommt optional stressfreiEmailId und
  zusätzlich trash/trashUnread im Result

Frontend:
- API-Client (getForContract/getTrash/getContractFolderCounts) nimmt
  Filter entgegen
- ContractEmailsSection reicht selectedAccountId in alle drei Queries
  + queryKey durch. Trash-Badge kommt jetzt aus contract-scoped
  Counts statt account-globalem stressfreiEmailApi
- EmailClientTab reicht selectedAccountId in die Trash-Query durch

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 14:06:24 +02:00
duffyduck f02824fe7d Pentest R89: Provider-Adressfelder härten
R89.1 MEDIUM + R89.2 LOW: sanitizeNotes(…, 500) macht silent
slice(0, 500) statt 400, und stripHtml lief vor dem Length-
Check – `<script>…</script>` reduzierte auf "" → null in DB
→ vorheriger Wert silent überschrieben (R87.1-Pattern auf
Adress-Feldern).

Fix: validateProviderAddress() in sanitize.ts – Raw-Input,
max 500 mit ApiError(400), Blacklist <, >, Tab + alle
Control-Chars außer \n. CRLF → LF VOR dem Length-Check, damit
Editoren mit \r\n-Line-Endings nicht doppelt zählen. Eingehängt
in stripProviderStrings für contactAddress/cancellationAddress.

R89.3/R89.4 (Quotes/\n) bewusst akzeptiert – Pentester selbst
sagt "kein Risiko", sind in Adressen legitim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 13:35:56 +02:00
duffyduck 8b10316683 Anbieter: Kontakt + Kündigung als Stammdaten
Sieben neue optionale Felder am Provider (contactEmail,
contactPhone, contactFax, contactAddress, cancellationEmail,
cancellationFax, cancellationAddress). Postadressen TEXT,
Rest VARCHAR(191). Migration mit IF NOT EXISTS.

Modal "Anbieter bearbeiten" bekommt neue Sektion "Kontakt &
Kündigung" mit zwei Untergruppen. Backend validiert Emails
gegen isValidEmail (Header-Injection-Schutz), Telefon/Fax
gegen sanitizePhoneField (kein CRLF), Postadressen via
sanitizeNotes mit 500-Cap. Factory-Defaults Export/Import
mitgezogen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 13:10:59 +02:00
duffyduck 26959ec909 Pentest R87: Identifier-Whitelist vor stripHtml ziehen
R87.1 LOW: stripHtml lief im R86-Fix VOR der Whitelist.
`<b>bold</b>` ging als `"bold"` mit 200 OK durch,
`<script>…</script>` reduzierte auf leeren String → null in DB
→ vorheriger Wert ohne Fehlermeldung überschrieben.

Fix: validateContractIdentifier läuft jetzt direkt gegen den
Raw-Input für die fünf Identifier-Felder. Die strikte Whitelist
lehnt eh alles ab, was stripHtml normalerweise auffangen würde
(Tags, Schemes, Zero-Width, Homoglyphe, Percent-Encoding) –
Defense-in-Depth bleibt, nur ehrlich (400 statt silent-200).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 12:50:45 +02:00
duffyduck c8b86ca9a7 Pentest R86: Vertrags-Identifier max 100 + Charset-Whitelist
R86.1 LOW + R86.2 LOW: >999-Zeichen liefen in DB-Overflow (500
statt 400), Attribut-Injection (`foo" onerror=…` ohne
umschließenden Tag) überlebte stripHtml.

Fix: validateContractIdentifier() (max 100,
^[A-Za-z0-9_\-/. ]{0,100}$) in sanitize.ts, eingehängt in
sanitizeContractBody. Wirft ApiError(400, …). Literales Space
statt \s → kein CRLF/Tab → kein Header-Injection-Vektor in
CSV-/Mail-/PDF-Export. Greift auf alle fünf Identifier-Felder
(Provider + Sales-Platform). ContractForm-Inputs bekommen
maxLength={100} als UX-Schicht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 14:14:00 +02:00
duffyduck 0b7bb89ebc Vertrag: Auftragsnummer Vertriebsplattform vor Kundennummer
Contract.orderNumberAtSalesPlatform (VARCHAR(191) NULL) mit
Migration 20260619100000_contract_order_number_at_sales_platform
(IF NOT EXISTS). Form-Input, Detail-Zeile mit Copy-Button,
Audit-Mapping, Renewal-Copy und XSS-Strip-Allowlist analog zu
den bestehenden Sales-Platform-Feldern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 13:49:04 +02:00
duffyduck 9274c0adaf Doku: URL-encoded Route-Params als by-design dokumentiert (R85-INFO) 2026-06-18 18:54:24 +02:00
duffyduck dfe2a4b241 Plesk-Sync: Auto-Import bei User-Remove deaktivieren
Folge-Bug zu 194c864: User löscht Adresse im Modal → DB-Liste
wird kürzer → Plesk-Sync läuft → Auto-Import sieht "c ist in
Plesk aber nicht in DB" → schreibt c zurück in
additionalForwardingEmails → Diff sagt nichts zu entfernen.

Ursache: Auto-Import (Pentest 83.x) lief für alle Sync-Pfade.
Beim Sync-Button ist Plesk→DB-Übernahme gewollt (Bestands-
Migration). Beim User-Add/Remove ist die DB-Liste die explizite
Intent – Auto-Import macht das User-Delete kaputt.

syncForwardingForEmail(id, opts?: { autoImportPleskMembers? })
mit Default true (Sync-Button-Verhalten). setAdditionalForwards
ruft mit false – entfernte Adressen verschwinden jetzt sauber
auch beim Provider.
2026-06-18 18:24:44 +02:00
duffyduck 194c86409f Plesk-Sync: del/add-Diff statt nicht-existierendem set:
Follow-up zu a83358b/24e152b. plesk bin mail --help auf Prod zeigt:
- -forwarding-addresses akzeptiert NUR add: und del:, kein set:
  → unser set:-Befehl wurde silent verworfen, Sync hatte nie
  Wirkung.
- -mailgroup als Option existiert gar nicht. Plesk nutzt -forwarding
  als Mailgroup-Schalter (im --info als "Mailgroup:" ausgegeben, im
  CLI als "-forwarding" gesetzt). Mein vorheriges -mailgroup false
  triggerte "Unrecognized option".

updateForwardTargets jetzt:
1. Aktuelle Members aus emailExists holen
2. Diff: toRemove = current \ targets, toAdd = targets \ current
   (case-insensitive)
3. Wenn toRemove: --update -forwarding-addresses del:<liste>
4. Wenn toAdd:    --update -forwarding true -forwarding-addresses add:<liste>

Idempotent, weil add/del Duplikate bzw. nicht-existente ignorieren.

Smoke-Test mit Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
nichts entfernt, nur bzirks@gmx.de hinzugefügt.
2026-06-18 18:16:57 +02:00
duffyduck 2becf6cb6a Plesk updateForwardTargets: CLI-Params + Response loggen
Sync zeigt im Prod-Log nur emailExists, kein update – entweder läuft
der Update-Code nicht durch oder Plesk lehnt ihn ab und wir sehen
es nicht (try/catch hat alles geschluckt).

CLI-Params vor dem Call loggen + Plesk-Response vollständig dumpen.
Zusätzlich Response auf code != 0 / stderr-Error prüfen statt
pauschal success=true zurückzugeben.
2026-06-18 18:07:12 +02:00
duffyduck 24e152b201 Pentest 83.1-83.3: Auto-Import-Pfad härten
83.1 MEDIUM: Auto-Import in syncForwardingForEmail rief
assertValidForwardingEmail nicht auf. Plesk-Member wie
attacker@plesk.internal wären ohne TLD-Block-Check (71.1) in
die DB importiert worden. Fix: jeder importierte Member läuft
durch assertValidForwardingEmail, ungültige werden silent gedroppt
+ auf debug-Level geloggt.

83.2 LOW: Self-Forward-Schutz (81.1) griff nur im Add-Pfad. Wenn
Plesk die eigene Adresse als Mailgroup-Member führte, wäre sie
beim Auto-Import in die DB-Liste gerutscht → nach dem Umschalten
auf Forwarding Mail-Loop. Fix: seenKeys mit der eigenen Adresse
initialisieren bevor die Import-Schleife läuft.

83.3 INFO: PII-Log auf console.debug umgestellt (statt console.log).

Smoke-Test mit gemischter Plesk-Liste: legitimer Member importiert,
reservierte TLDs + Self-Mail (exakt + Plus-Tag) abgelehnt,
Customer-Stamm + Default deduped.
2026-06-18 17:35:17 +02:00
duffyduck a83358bbe6 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht
Prod-Bug: zusätzliche Weiterleitung eintragen → Toast meldet
Erfolg, Plesk übernimmt nichts. Plesk hat zwei unabhängige
Verteil-Mechanismen, Mailgroup (alte CLI-Anlagen) und Forwarding
(neue). Unser Sync schrieb nur in Forwarding, die alte Adresse
lief aber via Mailgroup → set:-Befehle landeten in ungenutzter
Tabelle. Stage funktionierte, weil dort frisch im Forwarding-
Modus angelegt.

- EmailExistsResult um mailgroupActive/Members + forwardingActive/
  Targets erweitert.
- pleskProvider.emailExists parst alle vier Felder aus --info-
  stdout (Mailgroup: true|false, Group member(s): ..., Forward
  request: ...).
- pleskProvider.updateForwardTargets setzt -mailgroup false dazu –
  deaktiviert den Legacy-Mechanismus.
- syncForwardingForEmail holt vorm Plesk-Update die bestehenden
  Mailgroup-Members und Forwarding-Targets ab und importiert sie
  in unsere additionalForwardingEmails-Liste (canonical-Key-Dedup).
  Verlustfrei – kein Empfänger fällt beim Umschalten raus.

Smoke-Test mit echtem Plesk-stdout (User-Log): 3 Group-Members
sauber geparst, leeres "Forward request" als [] erkannt.
2026-06-18 17:22:08 +02:00
duffyduck 5bb048c534 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.
2026-06-18 15:55:01 +02:00
duffyduck b3469483ca Pentest 77.3 (LOW): requireIdParam blockt Float-IDs
Number.isInteger(parseInt('4.5')) ist true, weil parseInt den
Nachkomma-Teil silent verwirft. /.../4.5/... traf die echte ID 4
statt 400 zu liefern – gleiches für 4.0 und Exp-Notation (4e1).

Fix: vor dem Parsen Regex /^\\d+$/ gegen die rohe Route-Eingabe.
Nur reine Ziffern erlaubt, keine Floats / Exp / Vorzeichen /
Whitespace / Hex.

Smoke-Test (17 Cases): 4.0, 4.5, 4e1, 4E2, 0, -4, +4, 0x10, 1.0e0,
leading/trailing Space alle abgelehnt; 1, 4, 100, 9999999
durchgewunken.
2026-06-18 15:28:59 +02:00
duffyduck 8992bb7a5d Stressfrei-Adressen: Duplikate beim Anlegen ablehnen
Bug: dieselbe E-Mail-Adresse konnte beim selben Kunden mehrfach
angelegt werden – im Screenshot zwei identische Einträge nach
einem Doppel-Submit.

- createEmail: findFirst auf (customerId, email) case-insensitive,
  bei Treffer ApiError(409). Eigene Meldung für inaktive
  Duplikate (Hinweis: alten Eintrag reaktivieren statt neu anlegen).
- updateEmail: gleicher Check beim Umbenennen, NOT id-Exclude.
- Controller: catch-Blöcke honorieren ApiError.statusCode (vorher
  pauschal 400) → 409 kommt sauber an die UI durch.
- Frontend: updateMutation bekam onError, damit der Fehler nicht
  schlucken bleibt.
2026-06-18 14:01:35 +02:00
duffyduck 246999be01 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.
2026-06-18 13:41:16 +02:00
duffyduck 96a054aa1a Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen
Der "Weitere Weiterleitungen"-Button war bisher nur im Bearbeiten-
Modus sichtbar (provider-vorhanden + ID nötig). Jetzt erscheint er
auch im Anlegen-Modus, sobald "Beim E-Mail-Provider anlegen"
angehakt ist.

- Sub-Modal generalisiert: value/onChange-controlled.
  Mit email-Prop → API-Persist pro Änderung (Edit-Modus).
  Ohne email-Prop → reiner lokaler State (Create-Modus).
- Haupt-Modal trackt additionalForwards als eigenen State und
  ruft nach erfolgreicher createEmail einmalig
  updateAdditionalForwards mit der vollen Liste auf – ein zweiter
  Provider-Sync mit set: setzt die finale Liste.
- Counter-Badge am Button zeigt die Anzahl bereits eingegebener
  Adressen.
2026-06-18 11:20:03 +02:00
duffyduck 36beac98c9 Stressfrei-Adressen: zusätzliche Weiterleitungsziele
Pro StressfreiEmail können jetzt weitere Weiterleitungs-Adressen
gepflegt werden, die zusätzlich zur Stamm-E-Mail des Kunden und
zur globalen Default-Forward-Adresse an den Provider gepusht werden.

- Schema: StressfreiEmail.additionalForwardingEmails (TEXT/JSON-
  Array), Migration mit IF NOT EXISTS.
- syncForwardingForEmail liest die Zusatzliste mit und filtert
  Duplikate gegen customer.email + config.defaultForwardEmail
  (case-insensitive) raus.
- Neuer Endpoint PUT /api/stressfrei-emails/:id/additional-forwards
  mit Body { emails: string[] } – ersetzt die Liste komplett und
  syncht den Provider direkt nach. Hard-Cap 20 Adressen, Format-
  Validation per Regex, Audit-Log.
- Frontend: Button "Weitere Weiterleitungen" im Edit-Modus des
  StressfreiEmailModals (erscheint sobald die Adresse beim Provider
  vorhanden ist). Sub-Modal mit Liste + Add/Remove, Änderungen
  gehen sofort live.
2026-06-18 10:58:14 +02:00
duffyduck 60851450f6 Bugfixes: Zähler/Bankkarte/Ausweis/Zählerstand-Modal editierbar
Vier weitere Vorkommen desselben Anti-Patterns wie beim
AddressModal-Fix vom 2026-06-03: setFormData(getInitialFormData())
unbedingt im Render-Body, getriggert durch formData.X !== prop.X.
Jeder Tastendruck setzte den State zurück → kein Editieren möglich.

Fix in MeterModal (meterNumber), BankCardModal (iban),
IdentityDocumentModal (documentNumber), MeterReadingModal (value):
nach useEffect mit [entity?.id]-Dependency umgezogen.
2026-06-08 20:54:40 +02:00
duffyduck 523eab30d5 JpgToPdfModal: Bilder auf 2400px runterskalieren
Stage: 2 Handy-JPGs → 23 MB PDF. Smartphone-Fotos haben
4000-6000 px Kante, das macht auch ohne Re-Encode 5-10 MB pro
Bild → PDF wird riesig.

Beim Hinzufügen werden Bilder jetzt auf max 2400 px lange Kante
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
Quality 0.92 (Lightroom-Default) persistiert. Vorschau, Rotation/
Flip und PDF-Embed laufen alle auf dem skalierten Bild.

Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF.
2026-06-03 18:29:04 +02:00
duffyduck 2fee13d09e EmailDetail: ExternalLink-Icon beim "Zugeordnet zu"-Badge
Klick auf die Vertragsnummer öffnet weiterhin im selben Tab
(via React-Router Link). Neues Icon daneben öffnet den Vertrag in
einem neuen Browser-Tab – analog zum Pro-Tab-Link in CustomerDetail.
2026-06-03 18:20:10 +02:00
duffyduck 84cbf01706 Kunden-Tabs: ExternalLink-Icon neben jedem Reiter
Tabs-Komponente bekommt optionalen tabHrefBuilder(tabId)-Prop.
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
ExternalLink-Icon, das den Tab via ?tab=<id> in einem neuen
Browser-Tab öffnet.

CustomerDetail übergibt den Builder. URL-Param wird eh schon
für den Tab-Sync genutzt – Anhängen reicht.

Click-stopPropagation verhindert, dass der Klick auf das Icon
gleichzeitig den Tab-Wechsel auslöst.
2026-06-03 18:15:23 +02:00
duffyduck fcc3b04725 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform
Viele Vertriebsplattformen vergeben eigene Nummern, die nicht mit
denen des Endanbieters identisch sind. Zwei neue optionale Felder
unter "Anbieter & Tarif".

- Schema: Contract.customerNumberAtSalesPlatform +
  contractNumberAtSalesPlatform, Migration mit IF NOT EXISTS.
- ContractForm: zwei neue Inputs direkt unter den entsprechenden
  Provider-Feldern.
- ContractDetail: eigene Zeilen mit CopyButton.
- Audit-Log-Mapping + Renewal-Copy + XSS-Strip-Whitelist mitgezogen.
- Bonus: contractNumberAtProvider war im Renewal-Copy und Audit-
  Label-Mapping fehlend – mitkorrigiert.
2026-06-03 18:13:17 +02:00
duffyduck 101369c205 EmailDetail: Links immer im neuen Tab öffnen
Nach DOMPurify-Sanitize alle <a>-Elemente auf target="_blank" +
rel="noopener noreferrer" setzen. Letzteres verhindert
window.opener-Tab-Hijacking. Sanitize + DOM-Walk in useMemo, läuft
nur bei Wechsel der Email neu.
2026-06-03 18:06:19 +02:00
duffyduck e792fe4185 assertSafePdf: PDF-Streams vor Pattern-Scan ausblenden
Stage-Bug: User lädt zwei Handy-JPGs als PDF hoch → 415 mit
"PDF enthält JavaScript-Action". Die JPEG-Bytes im jsPDF-Output
enthielten zufällig die Byte-Folge "/JavaScript" → Pattern-Match
auf Binär-Daten statt PDF-Struktur.

Fix: stream..endstream-Blöcke vor dem Scan rauspatchen. Echte
PDF-Actions stehen IMMER außerhalb von Streams (Object-Dictionaries),
Binär-Streams (Bilder/Fonts/Komprimiertes) werden ignoriert.

Smoke-Test: jspdf-Style-PDF mit /JavaScript-Bytes im Stream
durchgewinkt, echte /OpenAction /S /JavaScript blockiert,
clean PDF OK.
2026-06-03 17:54:38 +02:00
duffyduck 7c18343a95 Bugfixes: Adresse-Modal + Upload-Limit auf 25 MB
1. AddressModal: Straße-Feld ließ sich nicht editieren. setFormData
   wurde im Render-Body aufgerufen, wenn formData.street !==
   address.street → Reset bei jedem Tastendruck. In useEffect mit
   [address?.id]-Dependency umgezogen.

2. Multer-Limit von 10 MB auf 25 MB in upload.routes.ts,
   gdpr.routes.ts, contract.routes.ts. Zwei Handy-Fotos zu PDF
   kratzten am alten Limit. FileUpload-Hinweistext angepasst.
2026-06-03 16:37:09 +02:00
duffyduck 5508d59652 SIM-Karten: Checkbox "eSIM" zwischen Hauptkarte und Multisim
Hardware-Plastikkarte vs. eSIM-Profil ist eigene Eigenschaft – eSIM
kann sowohl Hauptkarte als auch Multisim sein, deshalb dritter
Toggle statt entweder/oder.

- Schema: SimCard.isEsim Boolean default false, Migration mit
  IF NOT EXISTS.
- Backend: vier SimCard-Schreibpfade in contract.service.ts (Create,
  Update, Follow-Up, Renewal).
- UI: dritte Checkbox in ContractForm zwischen Hauptkarte und
  Multisim. ContractDetail zeigt blauen eSIM-Badge.
2026-06-03 16:13:24 +02:00
duffyduck 431792e8d9 JpgToPdfModal: PDF-Größe massiv reduziert
Stage-Bug: 2 Handy-JPGs à 2 MB → PDF >10 MB → Multer 413. Ursache:
Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB
auf (Quality 100 % ≠ "identisch zum Original", sondern "möglichst
viele Bits pro Pixel" – ein schon JPEG-komprimiertes Smartphone-
Foto wird so künstlich 4-8× größer).

Fix 1: Wenn Rotation/Flip unverändert (Standardfall), Original-
DataURL 1:1 in die PDF einbetten – kein Canvas-Roundtrip, keine
Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Format-Detection per
data:image/png-Prefix (PNG vs JPEG).

Fix 2: Bei Transformation toDataURL('image/jpeg', 0.95) statt 1.0.
Visuell identisch für Foto-Inhalte, 50-70 % kleiner.

Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
16-30 MB), 2 gedrehte ≈ 5-8 MB.
2026-06-03 16:06:05 +02:00
duffyduck d5dd3f5e7f Pentest 70.2 (LOW): 500 statt 415 bei verbotenem MIME
Globaler Error-Handler (index.ts:461) matcht /sind erlaubt|nicht
erlaubt/i auf 415. Die 70.1-Reject-Message "... WebP erlaubt" (ohne
"sind") rutschte durch und landete bei 500 + Error-Log-Spam.

Fix: "... WebP-Dateien sind erlaubt" macht den Regex happy. Andere
Routes nutzen alle schon dieselbe Phrase.
2026-06-03 15:32:34 +02:00
duffyduck a235c43f40 Pentest 70.1 (INFO): GIF/WebP-Whitelist in contract.routes Multer-Filter
contract.routes Vertragsdokumente: Multer-fileFilter blockte
image/gif + image/webp, obwohl validateUploadedFile sie zulässt.
Folge: GIF mit korrektem MIME 415, mit gespooftem MIME 201. Kein
Sicherheitsproblem (Magic-Byte ist der echte Guard), nur Konsistenz.
2026-06-03 15:21:24 +02:00
duffyduck 9cfd2e4a64 Pentest 69.3 (INFO): Magic-Byte-Validator auf Vertragsdokumente erweitert
contract.routes.ts Vertragsdokumente-Upload hatte bisher nur den
PDF-Inhalts-Scan aus 68.1. JPG/PNG-Uploads waren ungeprüft, ohne
canonical Rename – Pentester selbst attestiert "ohne Exploit-Pfad"
(Download-Layer fängt's), aber inkonsistent zu allen anderen
Upload-Pfaden.

- Refactor: detectType + validateUploadedFile aus upload.routes.ts
  in neue Middleware uploadFileTypeValidator.ts ausgelagert (Single
  Source of Truth, ~90 Zeilen Duplikation entfällt).
- contract.routes.ts: validateUploadedFile ersetzt
  scanUploadedPdfIfPresent → Magic-Byte + canonical Rename + PDF-Scan
  in einer Pipeline.
- pdfUploadSafety.ts: scanUploadedPdfIfPresent entfernt (tot).
2026-06-03 14:49:06 +02:00
duffyduck ec577e6d76 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Active-Content-Filter + Modal-Limit
68.1: Magic-Byte-Check prüfte nur %PDF-. PDFs mit /JavaScript, /JS,
/Launch, /EmbeddedFile, /RichMedia (Flash) kamen durch und wurden
inline ausgeliefert – Browser-Viewer ignorieren JS, Adobe Acrobat
nicht.

- Neuer Helper assertSafePdf(buf) in utils/sanitize.ts mit
  case-sensitivem String-Scan auf die fünf Action-Patterns
  (\b-Word-Boundary verhindert False-Positives bei /JSXForm etc.).
- Neue Middleware pdfUploadSafety.ts mit zwei Varianten:
  requireSafeUploadedPdf (PDF-only) und scanUploadedPdfIfPresent
  (durchwinkt JPG/PNG, scannt nur PDFs).
- Eingehängt in: upload.routes (Magic-Byte-Validator erweitert),
  gdpr.routes Vollmacht-Upload, pdfTemplate.routes Template-Upload,
  contract.routes Vertragsdokumente, cachedEmail.controller
  (saveAttachmentTo, saveAttachmentAsInvoice,
  saveAttachmentAsContractDocument).
- Inline-Vorschau bleibt – Pentester-Empfehlung "disposition=inline
  abschalten" wurde bewusst nicht umgesetzt (löst Acrobat-Risiko
  nicht, bricht aber ~20 UI-Stellen).
- Smoke-Test: 5 Payload-Typen abgelehnt, clean PDF + Non-PDF + JSXForm
  durchgewinkt.

68.2: JpgToPdfModal-Self-DoS – MAX_IMAGES=50, MAX_IMAGE_BYTES=25MB.
2026-06-03 13:18:23 +02:00
duffyduck 30f528596c JPGs → PDF: neuer Button überall bei PDF-Upload
- Neue Komponente JpgToPdfModal (jsPDF clientseitig, kein Backend-Roundtrip).
- Bilder hinzufügen per Klick, Drag&Drop oder Strg+V (Clipboard).
- Reihenfolge per Drag&Drop sortierbar; pro Bild 90°/180°-Drehung +
  horizontal/vertikal-Spiegelung.
- Jedes Bild = eine A4-Seite, Orientation automatisch nach Bild,
  JPEG-Qualität 100%.
- FileUpload-Komponente zeigt den Sekundär-Button automatisch, sobald
  accept PDF einschließt (Datenschutz, Vollmacht, Bankkarten, Ausweise,
  Gewerbeanmeldung, Handelsregister, Kündigungsschreiben/-bestätigung
  + jeweilige Optionen).
- Direktinputs ebenfalls erweitert: Vertragsdokumente (ContractDetail),
  Vollmacht-Tab (CustomerDetail), Rechnungen (InvoicesSection).
- PdfTemplates bewusst ausgenommen – braucht AcroForm-Felder.
2026-06-03 12:27:37 +02:00
duffyduck 358688db9e PDF-Templates: billingAddress.full und .country als Slots ergänzt
Analog zu address.full/.country: wer im Auftragsformular eine Zeile
"Rechnungsstraße 1, 10115 Berlin" als Single-Slot braucht, kann jetzt
billingAddress.full mappen statt Straße + PLZ + Stadt einzeln. Plus
billingAddress.country für Vollständigkeit.

Beide Slots greifen auf das gleiche bAddr-Resolve (Fallback auf
Lieferadresse) zu, wenn keine separate Rechnungsadresse hinterlegt
ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 15:17:48 +02:00
duffyduck ffb0d81b6a PDF-Templates: billingAddress fällt auf Lieferadresse zurück
Wie in der Kundenakte: wenn Contract.billingAddressId NULL ist
(= "Wie Lieferadresse"), liefern die billingAddress.*-Felder im
Auftragsformular jetzt die Werte der Lieferadresse statt leer
zu bleiben.

Konkret betrifft das die 6 Template-Variablen:
- billingAddress.street, houseNumber, streetFull
- billingAddress.postalCode, city, postalCodeCity

Anbieter, die ein vollständig befülltes "Rechnungsadresse"-Block
im PDF erwarten, bekommen es jetzt automatisch – kein manueller
Doppel-Eintrag der Adresse beim Kunden mehr nötig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 15:06:57 +02:00
duffyduck 25681075b4 Pentest 24.6 INFO + 26.7 LOW: PENDING-Status sperren + documentPath-Validator
24.6 (Portal kann Consent auf PENDING zurücksetzen):
- gdpr.controller updateCustomerConsent prüft jetzt explizit, dass
  der Portal-User nur GRANTED oder WITHDRAWN setzen kann. PENDING
  ist nur der initiale System-Status; ein Reset darauf hätte die
  DSGVO-Auswertung verfälscht.

26.7 (documentPath ohne Validierung):
- Neuer Helper isValidDocumentPath + assertValidDocumentPath in
  utils/sanitize: nur /?uploads/<safe>, keine "..", keine
  javascript:/data:/vbscript:, kein HTML.
- consent.service.updateConsent ruft den Assert auf – Defense-in-
  Depth gegen zukünftige Caller, die documentPath aus User-Input
  durchreichen könnten.
- authorization.service.grantAuthorization analog.
- Cleanup-Skript (prisma/cleanup-xss-and-mass-assignment) entfernt
  seine lokale Kopie der Path-Validierung und nutzt den shared
  Helper – Single Source of Truth.

27.1 (Altdaten in Staging-DB):
- Cleanup-Skript läuft sowieso bei jedem Container-Start. Nina-
  Records mit "../../../etc/passwd" werden beim nächsten Restart
  genullt (oder verschwinden mit dem VM-Snapshot-Wechsel).

Live-Test isValidDocumentPath: 13/13 OK – legitime Pfade durch,
Traversal/JS-URI/HTML blockiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 14:20:13 +02:00
duffyduck ad81a7c93e Pentest 64.1 LOW: ApiError-Klasse, Race-Lock liefert jetzt 400 statt 500
assertNoRecentDuplicateDocument warf einen generischen Error → die
Catch-Blöcke in den drei ContractDocument-Schreibpfaden mappten
das auf 500, obwohl es klar eine 400-Class-Situation (Caller-Fehler:
Duplikat-Submit) ist.

Neuer ApiError-Helper in utils/apiError:
- ApiError(statusCode, message) – einfache Subklasse von Error mit
  explizitem HTTP-Status.

assertNoRecentDuplicateDocument wirft jetzt ApiError(400, ...).

Catch-Blöcke gehärtet (Service-Pattern: `error instanceof ApiError
? error.statusCode : <default>`):
- contract.controller uploadContractDocument: 400-Default bleibt,
  ApiError wird honoriert; bonus: multer-Datei wird bei Reject jetzt
  gelöscht (war vorher orphaned bei Lock-Reject).
- cachedEmail.controller saveEmailAsContractDocument: 500-Default,
  ApiError → 400.
- cachedEmail.controller saveAttachmentAsContractDocument: dito.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 13:51:32 +02:00
duffyduck 518139438e Pentest 62.7 LOW: deliveryDate / confirmationDate ISO-8601-Validierung
Bisher gingen XSS-Payloads in deliveryDate (saveEmailAsContractDocument,
saveAttachmentAsContractDocument, uploadContractDocument) und
confirmationDate (Cancellation-Confirmation-Upload) mit 200 durch.
Das Datum wurde silent als null behandelt; Impact gering, aber
schlechte API-Hygiene.

Neuer validateOptionalIsoDate-Helper in utils/sanitize:
- ISO-8601-Regex YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS(.fff)?(Z|+HH:MM)?
- null / leerer String / undefined sind OK (Optional-Semantik)
- Sonstige Eingaben werfen 400 mit klarer Meldung

Eingesetzt in:
- contract.controller uploadContractDocument (multer-Datei wird bei
  Reject sauber gelöscht)
- cachedEmail.controller saveEmailAsContractDocument +
  saveAttachmentAsContractDocument: Validierung früh, BEVOR Dateien
  geschrieben werden – kein Datei-Müll bei Reject
- upload.routes handleContractDocumentUpload (cancellationConfirmation*)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 08:27:22 +02:00
duffyduck 5fa9d4d4f3 Pentest 60.3 MEDIUM: sanitizePhoneField auf Customer + User-Felder ausweiten
Der Fix aus 51.3 deckte nur Contract-PhoneNumber-Felder ab. CRLF in
`Customer.phone`, `Customer.mobile` und (im selben Code-Pfad)
`User.whatsappNumber`, `User.signalNumber` ging weiter durch –
pickCustomerUpdate / pickUserUpdate macht nur stripHtml, das filtert
keine Control-Chars.

- sanitizePhoneField von contract.service nach utils/sanitize gezogen
  und EXPORT, damit alle Stellen denselben Allowlist-Check
  (/^[0-9+\-/(). ]{0,40}$/) nutzen. Literales Space, NICHT \s.
- customer.controller updateCustomer + createCustomer: phone + mobile
  durch sanitizePhoneField → 400 bei CRLF/Control-Chars.
- user.controller updateUser + createUser: whatsappNumber +
  signalNumber analog.
- contract.service nutzt jetzt den importierten Helper (Lokale
  Kopie entfernt – Single Source of Truth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:40:40 +02:00
duffyduck f4ac1c29db Pentest 59.4 HIGH: IPv4-mapped IPv6 in SSRF-Guard blocken (alle Schreibweisen)
Node's URL-Parser normalisiert IPv4-mapped IPv6 von Dotted- in
Hex-Form: `::ffff:127.0.0.1` → `::ffff:7f00:1`,
`::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS-Metadata!),
`::ffff:10.0.0.1` → `::ffff:a00:1`.

Die bisherigen Patterns (`::ffff:127\.` etc.) matchten nur die
Dotted-Form. Sobald die URL durch `new URL()` lief, wurde der Host
in Hex-Form herausgereicht und kam an der Blocklist vorbei – live
verifiziert auf test-mail-access mit allen drei Payloads.

Fix in ssrfGuard.ts:
- Neuer extractMappedIPv4-Helper: erkennt Compact-Dotted,
  Compact-Hex, Expanded-Dotted, Expanded-Hex – konvertiert auf
  Dotted-IPv4.
- Neuer checkIPv4-Helper: läuft die IPv4 durch BLOCKED_PATTERNS
  und (optional) PRIVATE_IP_PATTERNS, mit BLOCKED/PRIVATE_HOSTNAMES.
- isBlockedSsrfHost + isPrivateOrBlockedHost rufen den IPv4-Check
  bei Mapped-IPv6 zusätzlich auf. Plain IPv4 und Hex-Form werden
  damit gleich behandelt.

Verifiziert mit 15-Tests: ::ffff:7f00:1, ::ffff:a9fe:a9fe,
0:0:0:0:0:ffff:7f00:1 etc. werden alle geblockt; legitime IPs
(8.8.8.8, ::ffff:8.8.8.8) bleiben durchlässig.

Nebenbefund (Consent-URL = localhost):
- getPublicUrl in auth.service jetzt EXPORT (vorher private).
- gdpr.controller (sendConsentLink + send-privacy-link) nutzt
  jetzt getPublicUrl statt direkt PUBLIC_URL/origin/localhost-
  Kette. Damit greift die admin-konfigurierte
  AppSetting `portalLoginUrl` auch hier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:16:19 +02:00
duffyduck 6b1d493f0b Pentest 58.1 MEDIUM: documentType jetzt mit echter Whitelist-Validierung
Bisher lief documentType nur durch stripHtml – ein beliebiger String
("NICHT_ERLAUBT", "DROP TABLE ...", Tippfehler) wurde 1:1 als
ContractDocument.documentType in die DB geschrieben. Das brach
Frontend-Filter, Lieferbestätigung-Auto-Activation und Reports.

Neuer validateContractDocumentType-Helper in utils/sanitize:
- Whitelist ALLOWED_CONTRACT_DOCUMENT_TYPES (8 Werte, gespiegelt aus
  Frontend CONTRACT_DOCUMENT_TYPES)
- Case-insensitiver Match, Rückgabe ist immer der kanonische Wert
- Wirft sprechende 400-Fehlermeldung mit Liste der erlaubten Werte

Eingesetzt in allen 3 Schreibpfaden:
- contract.controller.uploadContractDocument (multer-Datei wird bei
  Reject sauber gelöscht)
- cachedEmail.controller.saveEmailAsContractDocument
- cachedEmail.controller.saveAttachmentAsContractDocument

Audit-Log + maybeActivateOnDeliveryConfirmation nutzen jetzt den
kanonischen Wert (statt der rohen Eingabe), damit Reports
einheitlich aussehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:53:34 +02:00
duffyduck 9482424ade Pentest 57.7 MEDIUM + 57.8 MEDIUM: Consent-Hash-TTL + Zip-Slip-Härtung
57.7 (Consent-Hash ohne TTL):
- Neues Feld Customer.consentHashExpiresAt + Migration
  20260601300000_consent_hash_ttl mit IF NOT EXISTS. Bestandsdaten
  bekommen NOW()+30d als Default, damit frische Versand-Links nicht
  sofort sterben.
- TTL-Konstante CONSENT_HASH_TTL_DAYS = 30 in consent-public.service.
- getCustomerByConsentHash + grantAllConsentsPublic liefern null bzw.
  klare Fehlermeldung bei Ablauf; consentHashExpiresAt wird nicht in
  der Response durchgereicht (kein Oracle "unbekannt vs. abgelaufen").
- ensureConsentHash erneuert Hash + Frist, sobald der alte abgelaufen
  ist – Versand neuer Links bleibt friction-frei.
- consentHashExpiresAt in SENSITIVE_CUSTOMER_FIELDS (sanitize), damit
  der Standard-Customer-Endpoint kein Workflow-Info leakt.

57.8 (Zip-Slip / Zip-Bomb):
- Reject zusätzlich: leere Entry-Namen, Backslashes (Cross-OS-
  Confusion), Home-Dir-Expansion (`~`), explizite `..`-Segmente
  schon im Original-Namen (vor path.resolve).
- Zip-Slip-Check auf path.relative umgestellt – robuster als
  startsWith(prefix + sep), insbesondere bei nested Resolution.
- Zip-Bomb-Schutz: 500 MB pro Entry + 5 GB Gesamt-Uncompressed-
  Limit; bei Überschreitung Abbruch mit klarer Meldung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:13:06 +02:00
duffyduck a023e96012 Pentest 56.1/56.2/56.3/56.4/56.5: Ownership-Checks + InvoiceType-Validierung
56.1 HIGH (IDOR auf Upload-Endpoints):
- /upload/bank-cards/:id (POST/DELETE): canAccessBankCard +
  Existenz-Check, multer-Datei wird bei Reject sauber aufgeräumt.
- /upload/documents/:id (POST/DELETE): canAccessIdentityDocument
  + Existenz-Check + Cleanup.
- /upload/customers/:id/{business-registration,commercial-register,
  privacy-policy} (POST/DELETE): canAccessCustomer + Cleanup.
- /upload/invoices/:id (POST/DELETE): canAccessContract über
  Invoice→Contract-Resolve + Cleanup.

56.2 HIGH (IDOR + Consent-Eskalation bei privacy-policy):
- Vor dem upsert auf alle 4 CustomerConsent-Einträge (=GRANTED)
  läuft jetzt canAccessCustomer. Portal-Vertreter ohne Vollmacht
  oder Mitarbeiter mit anderer Customer-Beschränkung kommen
  damit nicht mehr durch.

56.3 LATENT (updateContract / deleteContract):
- Defense-in-Depth: canAccessContract jetzt explizit im Controller,
  nicht nur über die Route-Permission.

56.4 MEDIUM (invoiceType ungeprüft in addInvoiceByContract):
- Neuer assertValidInvoiceType-Helper mit Whitelist
  ['INTERIM','FINAL','NOT_AVAILABLE'] in addInvoice,
  updateInvoice und addInvoiceByContract. updateInvoice nur
  bei explizit gesetztem Wert; addInvoiceByContract zusätzlich
  die fehlende Required-Field-Validierung ergänzt.

56.5 LOW (GDPR-Löschanfragen ohne Ownership-Check):
- POST /api/gdpr/deletions liest customerId jetzt aus dem Body
  (Route hat kein :id-Segment), validiert auf positive Zahl und
  ruft canAccessCustomer auf, bevor die Löschanfrage erstellt wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:01:06 +02:00
duffyduck 72de2f00f3 Pentest 55.2 + 55.3 HIGH + 55.4 + 53.3: Notes/Document-Auth/Race/Generate
55.3 HIGH (Contract-Documents ohne Auth abrufbar):
- /uploads/contract-documents/*.pdf war HTTP 200 ohne Token, weil
  nginx die Datei direkt ausliefert und Backend nur /api/uploads/*
  schützte.
- Defense-in-Depth: app.get('/uploads/*') jetzt ebenfalls mit
  authenticate + downloadFile (Ownership-Check) abgesichert.
  Falls nginx fehlkonfiguriert sein sollte, fängt das Backend.

55.2 MEDIUM (notes ungestrippt + unlimitiert):
- Neuer sanitizeNotes-Helper: stripHtml + CRLF→LF + Control-Chars
  raus + Cap 2000 Zeichen. Eingesetzt für ContractDocument.notes
  in allen 3 Schreibpfaden (contract.controller, saveAttachment-
  AsContractDocument, saveEmailAsContractDocument).
- documentType zusätzlich stripHtml.

55.4 LOW (Race: 5x Lieferbestätigung → 5 Dokumente):
- Neuer In-Memory-Lock per (contractId, documentType) in
  contractStatusScheduler.service. withContractDocumentLock führt
  Recent-Duplicate-Check (10s-Window) + Write atomar aus.
- In cachedEmail-Pfaden: fs.writeFileSync ist jetzt INNERHALB des
  Locks → kein verwaister Datei-Müll bei Race-Reject.

53.3 (Prisma-Client veraltet bei ungebauten Images):
- docker-entrypoint.sh: `prisma generate` am Container-Start
  hinzugefügt. Kostet ~5–10 s, regeneriert den Client gegen das
  aktuelle Schema falls jemand ein Stale-Image hochgezogen hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:45:39 +02:00
63 changed files with 5457 additions and 484 deletions
+8
View File
@@ -89,6 +89,14 @@ if ! npx prisma migrate deploy; then
fi
echo "[entrypoint] DB-Schema aktuell"
# Pentest 53.3 (2026-06-01): wenn ein veraltetes Image gestartet wird
# (kein `docker compose build` nach Schema-Änderung), fehlten neue Felder
# wie `areaCode` im generierten Prisma-Client → PUT/POST crash. `prisma
# generate` am Start regeneriert den Client gegen das aktuelle Schema
# und kostet ~510 s tradeoff für Robustheit.
echo "[entrypoint] Prisma-Client regenerieren (falls Image älter als Schema)…"
npx prisma generate || echo "[entrypoint] prisma generate fehlgeschlagen nicht kritisch, Client bleibt aus Image"
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
USER_COUNT=$(node -e "
@@ -9,7 +9,7 @@
* mehrfach aufrufbar.
*/
import prisma from '../src/lib/prisma.js';
import { stripHtml } from '../src/utils/sanitize.js';
import { stripHtml, isValidDocumentPath as isValidDocumentPathShared } from '../src/utils/sanitize.js';
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
const CUSTOMER_STRING_FIELDS = [
@@ -93,18 +93,10 @@ const ALLOWED_CONSENT_SOURCES = new Set([
]);
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
// raus (Pentest 2026-05-20 LOW 27.1).
function isValidDocumentPath(v: string | null | undefined): boolean {
if (!v) return true; // null/leer ist OK
if (v.includes('..')) return false;
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
if (/<[a-z!\/]/i.test(v)) return false; // HTML im Pfad
// erlaubt: "uploads/...", "/uploads/..."; keine Kontrollzeichen
return /^\/?uploads\/[A-Za-z0-9._\-\/]+$/.test(v);
}
// Pentest 27.1 (2026-05-20). Helper jetzt zentral in utils/sanitize wir
// re-exportieren hier nur, damit der bestehende Code keinen Schaden nimmt
// und wir nur EINE Quelle der Wahrheit pflegen müssen.
const isValidDocumentPath = isValidDocumentPathShared;
async function cleanupConsents() {
// version + documentPath: HTML strippen (waren ohne Validierung).
@@ -0,0 +1,16 @@
-- Consent-Hash bekommt eine Ablauffrist (Pentest 57.7 MEDIUM).
-- Public-Consent-Links liefen vorher nie ab DSGVO-Risiko, weil ein
-- weitergegebener Link Jahre später noch Einwilligungen erteilen konnte.
-- 30 Tage Default; nach Ablauf liefert getCustomerByConsentHash null.
-- Bestandsdaten ohne Ablaufzeit bekommen `NOW() + 30 Tage` als Frist,
-- damit existierende, frisch versendete Links nicht sofort tot sind.
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher.
ALTER TABLE `Customer`
ADD COLUMN IF NOT EXISTS `consentHashExpiresAt` DATETIME(3) NULL;
UPDATE `Customer`
SET `consentHashExpiresAt` = DATE_ADD(NOW(), INTERVAL 30 DAY)
WHERE `consentHash` IS NOT NULL
AND `consentHashExpiresAt` IS NULL;
@@ -0,0 +1,8 @@
-- SIM-Karte bekommt ein optionales `isEsim`-Flag Hardware-Plastikkarte
-- vs. eSIM-Profil. UI-Position: zwischen "Hauptkarte" und "Multisim".
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
-- `prisma db push` gefahren hat.
ALTER TABLE `SimCard`
ADD COLUMN IF NOT EXISTS `isEsim` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,7 @@
-- Vertrieb-/Provider-Trennung: viele Plattformen vergeben eigene Kunden-/
-- Vertragsnummern, die nicht mit denen beim Endanbieter identisch sind.
-- Zwei neue optionale Felder unter "Anbieter & Tarif".
ALTER TABLE `Contract`
ADD COLUMN IF NOT EXISTS `customerNumberAtSalesPlatform` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contractNumberAtSalesPlatform` VARCHAR(191) NULL;
@@ -0,0 +1,7 @@
-- Zusätzliche Weiterleitungs-E-Mails pro StressfreiEmail-Adresse.
-- JSON-Array (z.B. `["info@partner.de","cc@kanzlei.de"]`), wird beim
-- Sync zusammen mit customer.email + config.defaultForwardEmail an den
-- Provider gepusht (`set:`-Befehl überschreibt die Liste).
ALTER TABLE `StressfreiEmail`
ADD COLUMN IF NOT EXISTS `additionalForwardingEmails` TEXT NULL;
@@ -0,0 +1,7 @@
-- Zusätzliches optionales Feld unter "Anbieter & Tarif": Auftragsnummer bei
-- der Vertriebsplattform (vor der Kundennummer). Plattformen liefern beim
-- Abschluss oft eine eigene Auftrags-/Vorgangsnummer, die fürs Reklamations-
-- handling gebraucht wird.
ALTER TABLE `Contract`
ADD COLUMN IF NOT EXISTS `orderNumberAtSalesPlatform` VARCHAR(191) NULL;
@@ -0,0 +1,13 @@
-- Provider: separate Kontakt- + Kündigungs-Daten als Stammsatz.
-- Vorher musste der CRM-Mitarbeiter Tel/Email/Adresse pro Anbieter
-- selbst nachschlagen; jetzt direkt im Anbieter-Datensatz hinterlegt.
-- Postadressen sind TEXT (mehrzeilig), alle anderen VARCHAR(191).
ALTER TABLE `Provider`
ADD COLUMN IF NOT EXISTS `contactEmail` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactPhone` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactFax` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactAddress` TEXT NULL,
ADD COLUMN IF NOT EXISTS `cancellationEmail` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `cancellationFax` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `cancellationAddress` TEXT NULL;
+21 -1
View File
@@ -157,7 +157,8 @@ model Customer {
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
commercialRegisterNumber String? // Handelsregisternummer (Text)
privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden)
consentHash String? @unique // Permanenter Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
consentHash String? @unique // Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
consentHashExpiresAt DateTime? // Pentest 57.7: TTL für Public-Consent-Link (30 Tage Default); nach Ablauf ist getCustomerByConsentHash null und der Link muss neu generiert werden.
notes String? @db.Text
// ===== Portal-Zugangsdaten =====
@@ -401,6 +402,12 @@ model StressfreiEmail {
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
// Zusätzliche Weiterleitungsziele (über die Stamm-E-Mail des Kunden
// hinaus). Wird beim Sync zusammen mit customer.email +
// config.defaultForwardEmail an den Provider geschickt. JSON-Array
// von Strings, z.B. `["info@partner.de","cc@kanzlei.de"]`.
additionalForwardingEmails String? @db.Text
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
createdAt DateTime @default(now())
@@ -569,6 +576,15 @@ model Provider {
portalUrl String? // Kundenkontourl (Login-Seite)
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
// Kontaktdaten beim Anbieter (für CRM-Mitarbeiter zum Nachschlagen)
contactEmail String? // Allgemeine Kontakt-Emailadresse
contactPhone String? // Kontakt-Telefonnummer
contactFax String? // Kontakt-Faxnummer
contactAddress String? @db.Text // Kontakt-Postadresse (mehrzeilig)
// Dedizierte Kündigungs-Endpunkte (wenn separat vom allgemeinen Kontakt)
cancellationEmail String? // Kündigungs-Emailadresse
cancellationFax String? // Kündigungs-Faxnummer
cancellationAddress String? @db.Text // Kündigungs-Postadresse (mehrzeilig)
isActive Boolean @default(true)
tariffs Tariff[]
contracts Contract[]
@@ -686,6 +702,9 @@ model Contract {
tariffName String?
customerNumberAtProvider String?
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
orderNumberAtSalesPlatform String? // Auftragsnummer bei der Vertriebsplattform
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
priceFirst12Months String? // Preis erste 12 Monate
priceFrom13Months String? // Preis ab 13. Monat
priceAfter24Months String? // Preis nach 24 Monaten
@@ -933,6 +952,7 @@ model SimCard {
puk String? // PUK (verschlüsselt gespeichert)
isMultisim Boolean @default(false) // Ist dies eine Multisim-Karte?
isMain Boolean @default(false) // Ist dies die Hauptkarte?
isEsim Boolean @default(false) // Ist dies eine eSIM?
// Tatsächlicher Nutzer der SIM-Karte (z.B. Mitarbeiter bei Firmenverträgen,
// Kind bei Eltern-Vertrag) kann vom Vertragsinhaber abweichen.
cardUser String?
+276 -33
View File
@@ -8,11 +8,13 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
import { decrypt } from '../utils/encryption.js';
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate, assertSafePdf } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
import { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js';
import path from 'path';
@@ -39,12 +41,80 @@ function parseDateParam(v: unknown): Date | undefined {
return isNaN(d.getTime()) ? undefined : d;
}
// Pentest 91.1 (LOW, 2026-06-21): `accountId=abc` → `parseInt` = `NaN`
// → der Ternary gab `NaN` an den Service. `if (NaN)` ist falsy → der
// Postfach-Filter fiel weg, und der Vertrag zeigte Mails aus ALLEN
// Postfächern.
//
// Pentest 92 (LOW, 2026-06-21): Bei `accountId=abc` auf Vertrags-
// Endpunkten reichte das silent-undefined nicht die Mailbox-Isolation
// brach (man sah Mails aus allen Postfächern statt 0). Strict-400, weil
// Verträge per Design IMMER ein bestimmtes Postfach meinen.
//
// Helper hat zwei Modi:
// - default (optional): fehlend/leer → undefined (kein Filter)
// invalid → 400
// - { required: true }: fehlend/leer → 400
// invalid → 400
// Bei 400 schreibt der Helper direkt die Response und gibt `null`
// zurück; der Caller bricht dann mit `return` ab.
function parsePositiveIntQuery(
v: unknown,
fieldLabel: string,
res: Response,
options?: { required?: boolean },
): number | undefined | null {
// Pentest 93.1 (INFO, 2026-06-21): `?accountId=` (explizit-leer) wurde
// wie `?accountId` weggelassen behandelt → 200 statt 400 auf optionalen
// Endpunkten. Spec sagt aber: leerer String ist KEINE gültige Zahl.
// Trennung jetzt strikt:
// - Param fehlt komplett (`undefined`) → "absent"
// - Param da, aber Wert leer/Whitespace/keine Zahl → invalid → 400
if (v === undefined) {
if (options?.required) {
res.status(400).json({
success: false,
error: `${fieldLabel} ist erforderlich (positive Ganzzahl).`,
} as ApiResponse);
return null;
}
return undefined;
}
if (typeof v !== 'string') {
res.status(400).json({
success: false,
error: `${fieldLabel} muss als Zahl übergeben werden.`,
} as ApiResponse);
return null;
}
const trimmed = v.trim();
if (trimmed === '') {
res.status(400).json({
success: false,
error: `${fieldLabel} darf nicht leer sein bitte weglassen oder positive Ganzzahl angeben.`,
} as ApiResponse);
return null;
}
const n = parseInt(trimmed, 10);
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
res.status(400).json({
success: false,
error: `${fieldLabel} muss eine positive Ganzzahl sein.`,
} as ApiResponse);
return null;
}
return n;
}
// E-Mails für einen Kunden abrufen
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
// Customer-Inbox: accountId ist legitim optional (cross-mailbox-Ansicht
// ist erwünscht), aber invalide Werte → 400.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) return;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
@@ -79,17 +149,26 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
}
}
// E-Mails für einen Vertrag abrufen
// E-Mails für einen Vertrag abrufen.
// `accountId` (optional) schränkt zusätzlich auf ein bestimmtes Postfach
// ein ohne, sieht man im Vertrags-Tab Mails aus ALLEN Postfächern des
// Kunden, die dem Vertrag zugeordnet sind (User-Bug 2026-06-21).
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
// Vertrags-Endpunkte sind per Design IMMER pro Postfach fehlt
// accountId, ist die Abfrage semantisch ungültig. Strict-400.
// Frontend hat eh ein `enabled: !!selectedAccountId`-Guard.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
const emails = await cachedEmailService.getCachedEmails({
contractId,
stressfreiEmailId,
folder,
limit,
offset,
@@ -236,13 +315,17 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise<
}
}
// E-Mail-Anzahl pro Ordner für einen Vertrag
// E-Mail-Anzahl pro Ordner für einen Vertrag (optional pro Postfach)
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
// Wie getEmailsForContract: Postfach ist required (sonst zeigt der
// Badge eine andere Zahl als die Liste).
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
const counts = await cachedEmailService.getFolderCountsForContract(contractId, stressfreiEmailId);
res.json({ success: true, data: counts } as ApiResponse);
} catch (error) {
@@ -309,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
return false;
}
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
const MAX_ATTACHMENT_COUNT = 25;
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
interface AttachmentValidationError {
status: 400;
error: string;
}
function validateAttachments(
attachments: unknown,
): { ok: true } | AttachmentValidationError {
if (attachments === undefined) return { ok: true };
if (!Array.isArray(attachments)) {
return { status: 400, error: 'attachments muss ein Array sein.' };
}
if (attachments.length > MAX_ATTACHMENT_COUNT) {
return {
status: 400,
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
};
}
let totalBytes = 0;
for (let i = 0; i < attachments.length; i++) {
const a = attachments[i];
const label = `Anhang ${i + 1}`;
if (!a || typeof a !== 'object') {
return { status: 400, error: `${label} hat das falsche Format.` };
}
const filename = (a as Record<string, unknown>).filename;
const content = (a as Record<string, unknown>).content;
const contentType = (a as Record<string, unknown>).contentType;
if (typeof filename !== 'string' || filename.trim() === '') {
return { status: 400, error: `${label} hat keinen Dateinamen.` };
}
if (typeof content !== 'string' || content.length === 0) {
return {
status: 400,
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
};
}
if (!BASE64_RE.test(content)) {
return {
status: 400,
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
};
}
if (contentType !== undefined && typeof contentType !== 'string') {
return {
status: 400,
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
};
}
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
const approxBytes = Math.ceil(content.length * 0.75);
if (approxBytes > MAX_PER_FILE_BYTES) {
return {
status: 400,
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
};
}
totalBytes += approxBytes;
if (totalBytes > MAX_TOTAL_BYTES) {
return {
status: 400,
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
};
}
}
return { ok: true };
}
// E-Mail senden
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -325,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
return;
}
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
// (Format, Größe, Anzahl) sonst leakte der rohe Node.js-Fehler
// in die Response und Limits waren nur Frontend-Doku.
const attachmentCheck = validateAttachments(attachments);
if (!('ok' in attachmentCheck)) {
res.status(attachmentCheck.status).json({
success: false,
error: attachmentCheck.error,
} as ApiResponse);
return;
}
// StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
@@ -880,13 +1056,25 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
// ==================== TRASH OPERATIONS ====================
// Papierkorb-E-Mails für einen Kunden abrufen
// Papierkorb-E-Mails für einen Kunden abrufen.
// Optional `accountId` (Postfach-Filter) und `contractId` (Vertrags-Filter)
// beide aus User-Bug 2026-06-21. Wenn beide leer sind, Verhalten wie
// vorher: alle gelöschten E-Mails des Kunden.
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Trash auf Kundenebene: Filter sind optional (Cross-Mailbox-Trash-
// View ist legitim), invalide Werte → 400.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) return;
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
if (contractId === null) return;
const emails = await cachedEmailService.getTrashEmails(customerId);
const emails = await cachedEmailService.getTrashEmails(customerId, {
stressfreiEmailId,
contractId,
});
res.json({ success: true, data: emails } as ApiResponse);
} catch (error) {
@@ -898,13 +1086,20 @@ export async function getTrashEmails(req: AuthRequest, res: Response): Promise<v
}
}
// Papierkorb-Anzahl für einen Kunden
// Papierkorb-Anzahl für einen Kunden (gleiche Filter wie getTrashEmails)
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) return;
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
if (contractId === null) return;
const count = await cachedEmailService.getTrashCount(customerId);
const count = await cachedEmailService.getTrashCount(customerId, {
stressfreiEmailId,
contractId,
});
res.json({ success: true, data: { count } } as ApiResponse);
} catch (error) {
@@ -1281,6 +1476,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/${targetDir}/${newFilename}`;
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen (JS/Launch/Embed).
assertSafePdf(attachment.content);
// Datei speichern
fs.writeFileSync(filePath, attachment.content);
@@ -1431,8 +1629,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
} catch (error) {
console.error('saveAttachmentTo error:', error);
// Detailliertere Fehlermeldung für Debugging
const status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
res.status(status).json({
success: false,
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
} as ApiResponse);
@@ -1826,8 +2025,21 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { documentType, notes } = req.body;
if (!documentType || typeof documentType !== 'string') {
res.status(400).json({ success: false, error: 'documentType ist erforderlich' } as ApiResponse);
// Pentest 58.1: Whitelist-Validierung des documentType.
let validatedType: string;
try {
validatedType = validateContractDocumentType(documentType);
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger documentType' } as ApiResponse);
return;
}
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
let deliveryDate: string | null;
try {
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
return;
}
@@ -1871,33 +2083,40 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
const newFilename = `${safeType}-email-${uniqueSuffix}.pdf`;
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
// PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
// Datei-Müll bei Race-Reject.
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
fs.writeFileSync(filePath, pdfBuffer);
const doc = await prisma.contractDocument.create({
return prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentType: validatedType,
documentPath: relativePath,
originalName: `${email.subject || 'email'}.pdf`,
notes: notes || null,
notes: sanitizeNotes(notes),
uploadedBy: (req as any).user?.email || 'email-import',
},
});
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
// deliveryDate wurde oben schon validiert (Pentest 62.7).
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
console.error('saveEmailAsContractDocument error:', error);
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
// Lock) statt pauschal 500.
const status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
}
}
@@ -2032,6 +2251,9 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/invoices/${newFilename}`;
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
assertSafePdf(attachment.content);
// Datei speichern
fs.writeFileSync(filePath, attachment.content);
@@ -2056,8 +2278,9 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
} as ApiResponse);
} catch (error) {
console.error('saveAttachmentAsInvoice error:', error);
const status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
res.status(status).json({
success: false,
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
} as ApiResponse);
@@ -2075,14 +2298,27 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
const filename = decodeURIComponent(req.params.filename);
const { documentType, notes } = req.body;
if (!documentType || typeof documentType !== 'string') {
// Pentest 58.1: Whitelist-Validierung des documentType.
let validatedType: string;
try {
validatedType = validateContractDocumentType(documentType);
} catch (err) {
res.status(400).json({
success: false,
error: 'documentType ist erforderlich',
error: err instanceof Error ? err.message : 'Ungültiger documentType',
} as ApiResponse);
return;
}
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
let deliveryDate: string | null;
try {
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
return;
}
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
@@ -2168,33 +2404,40 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
const ext = path.extname(filename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, attachment.content);
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
assertSafePdf(attachment.content);
const doc = await prisma.contractDocument.create({
// Pentest 55.4: Lock vor Schreiben (siehe saveEmailAsContractDocument).
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
fs.writeFileSync(filePath, attachment.content);
return prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentType: validatedType,
documentPath: relativePath,
originalName: filename,
notes: notes || null,
notes: sanitizeNotes(notes),
uploadedBy: (req as any).user?.email || 'email-import',
},
});
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
// deliveryDate wurde oben schon validiert (Pentest 62.7).
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
console.error('saveAttachmentAsContractDocument error:', error);
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
const status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
res.status(status).json({
success: false,
error: `Fehler beim Speichern: ${errorMessage}`,
} as ApiResponse);
+77 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import fs from 'fs';
import prisma from '../lib/prisma.js';
import * as contractService from '../services/contract.service.js';
import * as contractCockpitService from '../services/contractCockpit.service.js';
@@ -7,9 +8,10 @@ import * as authorizationService from '../services/authorization.service.js';
import { recordPredecessorFinalReading } from '../services/customer.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml } from '../utils/sanitize.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
@@ -34,6 +36,29 @@ function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
if (body === null || body === undefined) return body;
if (typeof body === 'string') {
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
// Pentest 86.1/86.2 (LOW, 2026-06-19): Längen- + Whitelist-Check auf
// Kunden-/Vertrags-/Auftragsnummer-Feldern. validateContractIdentifier
// wirft ApiError(400) bei Verstoß → saubere 400-Antwort statt 500.
//
// Pentest 87.1 (LOW, 2026-06-19): Identifier-Felder MÜSSEN gegen den
// Raw-Input geprüft werden, NICHT gegen den stripHtml-Output. Sonst
// verschluckt der Sanitizer Tag-Verstöße still: `<b>bold</b>` würde
// als `"bold"` mit 200 OK durchgehen, `<script>alert(1)</script>`
// sogar zu `null` und damit den vorherigen Wert überschreiben.
// Die strikte Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) deckt alle
// Bypässe ab, die stripHtml normalerweise auffangen würde
// (Tags, Schemes, Zero-Width-Chars, Homoglyphe, Percent-Encoding)
// sie sind alle nicht in der Allowlist und fliegen mit 400 raus.
if (parentKey && isContractIdentifierField(parentKey)) {
return validateContractIdentifier(body, parentKey);
}
// Pentest 95.1/95.3/95.4 (LOWMEDIUM, 2026-06-21): portalUsername
// (Manual-Modus) hatte gar keine Validierung CRLF/Header-Injection,
// silent stripHtml-Mutation und VARCHAR-Overflow möglich. Gleiches
// Raw-Input-Pattern wie R87.
if (parentKey === 'portalUsername') {
return validatePortalUsername(body, parentKey);
}
return stripHtml(body);
}
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
@@ -181,6 +206,11 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
// Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth
// canAccessContract explizit aufrufen, statt sich nur auf die
// Route-Permission zu verlassen. Portal-User mit kompromittierter
// Token-Permission würden sonst beliebige Verträge editieren können.
if (!(await canAccessContract(req, res, contractId))) return;
// Vorherigen Stand laden für Audit-Vergleich
const before = await prisma.contract.findUnique({
where: { id: contractId },
@@ -196,6 +226,10 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
const fieldLabels: Record<string, string> = {
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform',
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
@@ -264,6 +298,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
export async function deleteContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
// Pentest 56.3 (latent): Defense-in-Depth Ownership-Check vor Delete.
if (!(await canAccessContract(req as AuthRequest, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await contractService.deleteContract(contractId);
await logChange({
@@ -714,7 +750,7 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { documentType, notes, deliveryDate } = req.body;
const { documentType, notes } = req.body;
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
@@ -726,33 +762,62 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
return;
}
// Pentest 62.7: deliveryDate validieren (ISO-8601 oder null).
let deliveryDate: string | null;
try {
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
} catch (err) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
return;
}
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
const doc = await prisma.contractDocument.create({
// Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
// die Datei schon geschrieben bei Reject räumen wir sie wieder weg.
let cleanType: string;
try {
cleanType = validateContractDocumentType(documentType);
} catch (err) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger Dokumenttyp' } as ApiResponse);
return;
}
// Pentest 55.4: Race-Schutz Lock + Recent-Duplicate-Check.
const doc = await withContractDocumentLock(contractId, cleanType, () =>
prisma.contractDocument.create({
data: {
contractId,
documentType,
documentType: cleanType,
documentPath,
originalName: req.file.originalname,
notes: notes || null,
originalName: req.file!.originalname,
notes: sanitizeNotes(notes),
uploadedBy: req.user?.email,
},
});
}),
);
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await logChange({
req, action: 'CREATE', resourceType: 'ContractDocument',
resourceId: doc.id.toString(),
label: `Dokument "${documentType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
details: { typ: documentType, datei: req.file.originalname },
label: `Dokument "${cleanType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
details: { typ: cleanType, datei: req.file.originalname },
customerId: contract?.customerId,
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
await maybeActivateOnDeliveryConfirmation(contractId, cleanType, req, deliveryDate);
res.status(201).json({ success: true, data: doc } as ApiResponse);
} catch (error) {
res.status(400).json({
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
// Schreibfehler.
const status = error instanceof ApiError ? error.statusCode : 400;
// Multer hat die Datei schon geschrieben bei Reject räumen.
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
} as ApiResponse);
@@ -11,6 +11,7 @@ import {
sanitizeCustomerStrict,
pickCustomerCreate,
pickCustomerUpdate,
sanitizePhoneField,
isValidEmail,
} from '../utils/sanitize.js';
import {
@@ -90,6 +91,20 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
// 60.3: Phone/Mobile auch beim Create gegen Header-Injection sichern.
try {
if ('phone' in data) {
const cleaned = sanitizePhoneField(data.phone, 'Telefon');
data.phone = cleaned ?? null;
}
if ('mobile' in data) {
const cleaned = sanitizePhoneField(data.mobile, 'Mobil');
data.mobile = cleaned ?? null;
}
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse);
return;
}
// Convert birthDate string to Date if present
if (data.birthDate) {
data.birthDate = new Date(data.birthDate);
@@ -132,6 +147,23 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
}
const data: any = pickCustomerUpdate(req.body);
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
// jetzt zusätzlich durch sanitizePhoneField (Allowlist).
try {
if ('phone' in data) {
const cleaned = sanitizePhoneField(data.phone, 'Telefon');
data.phone = cleaned ?? null;
}
if ('mobile' in data) {
const cleaned = sanitizePhoneField(data.mobile, 'Mobil');
data.mobile = cleaned ?? null;
}
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({ where: { id: customerId } });
@@ -87,48 +87,72 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
// Stored-XSS gerendert.
//
// Default: Content-Disposition: attachment → Browser lädt nur runter.
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
// ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
// Magic eines bekannten safe Typs (PDF, PNG, JPEG, GIF, WebP) zeigen.
// Bei Mismatch fällt's auf attachment zurück Stored XSS bleibt
// weiterhin unmöglich.
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
// attachment zurück Stored XSS bleibt weiterhin unmöglich.
//
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
const wantsInline = req.query.disposition === 'inline';
let useInline = false;
let inlineContentType: string | null = null;
if (wantsInline) {
try {
const fd = fs.openSync(absolute, 'r');
const head = Buffer.alloc(12);
fs.readSync(fd, head, 0, 12, 0);
fs.closeSync(fd);
if (head.subarray(0, 5).toString('latin1') === '%PDF-') {
useInline = true;
inlineContentType = 'application/pdf';
} else if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
useInline = true;
inlineContentType = 'image/png';
} else if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
useInline = true;
inlineContentType = 'image/jpeg';
} else if (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|| head.subarray(0, 6).toString('latin1') === 'GIF89a') {
useInline = true;
inlineContentType = 'image/gif';
} else if (head.subarray(0, 4).toString('latin1') === 'RIFF'
&& head.subarray(8, 12).toString('latin1') === 'WEBP') {
useInline = true;
inlineContentType = 'image/webp';
}
} catch { /* ignore fällt auf attachment zurück */ }
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
if (wantsInline && !safeContentType) {
console.warn(
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
);
}
res.setHeader('X-Content-Type-Options', 'nosniff');
if (useInline && inlineContentType) {
res.setHeader('Content-Type', inlineContentType);
if (safeContentType) {
res.setHeader('Content-Type', safeContentType);
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
} else {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
}
res.sendFile(absolute);
}
/**
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
* entspricht. Sonst `null` dann wird die Datei als attachment serviert.
*
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
*/
function detectSafeContentType(absolute: string): string | null {
let fd: number | null = null;
try {
fd = fs.openSync(absolute, 'r');
const head = Buffer.alloc(12);
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
if (bytesRead >= 8
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) return 'image/png';
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
if (bytesRead >= 6
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
) return 'image/gif';
if (bytesRead >= 12
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
) return 'image/webp';
return null;
} catch (err) {
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
return null;
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
}
+35 -3
View File
@@ -4,6 +4,7 @@ import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js';
import * as consentPublicService from '../services/consent-public.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { getPublicUrl } from '../services/auth.service.js';
import { canAccessCustomer } from '../utils/accessControl.js';
import { createAuditLog, logChange } from '../services/audit.service.js';
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
@@ -65,7 +66,20 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
*/
export async function createDeletionRequest(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.5 (LOW, 2026-06-01): customerId muss als gültige Zahl
// aus dem Body kommen (Route hat kein :id-Segment) und der Caller
// braucht Zugriff auf den Kunden. Ohne den Check konnte jemand mit
// gdpr:delete-Permission Löschanfragen für beliebige Kunden stellen
// (Insider-Sabotage durch Portal-Vertreter ohne Vollmacht).
const bodyCustomerId = req.body?.customerId;
const customerId = typeof bodyCustomerId === 'number'
? bodyCustomerId
: parseInt(bodyCustomerId);
if (!Number.isFinite(customerId) || customerId < 1) {
res.status(400).json({ success: false, error: 'customerId fehlt oder ungültig' });
return;
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const { requestSource } = req.body;
const request = await gdprService.createDeletionRequest({
@@ -295,6 +309,18 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
}
// Pentest 24.6 (INFO, 2026-06-02): Portal-User durfte `PENDING`
// mitschicken und damit den Consent-Status auf den initialen System-
// Status zurücksetzen. PENDING ist nur intern (Default beim
// Customer-Anlegen); Portal darf nur GRANTED oder WITHDRAWN setzen.
// Verfälschte sonst die DSGVO-Auswertung.
if (status !== 'GRANTED' && status !== 'WITHDRAWN') {
return res.status(400).json({
success: false,
error: 'Portal-Einwilligungen dürfen nur auf GRANTED oder WITHDRAWN gesetzt werden.',
});
}
const consentLabels: Record<string, string> = {
DATA_PROCESSING: 'Datenverarbeitung',
MARKETING_EMAIL: 'E-Mail-Marketing',
@@ -559,7 +585,10 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
// ConsentHash sicherstellen
const hash = await consentPublicService.ensureConsentHash(customerId);
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
// getPublicUrl nimmt zuerst die admin-konfigurierte AppSetting
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
const baseUrl = await getPublicUrl();
const consentUrl = `${baseUrl}/datenschutz/${hash}`;
// Bei E-Mail: tatsächlich senden
@@ -702,7 +731,10 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
}
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
// getPublicUrl nimmt zuerst die admin-konfigurierte AppSetting
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
const baseUrl = await getPublicUrl();
const portalUrl = `${baseUrl}/privacy`;
// E-Mail senden
@@ -53,6 +53,23 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
/**
* Neue Rechnung hinzufügen
*/
// Pentest 56.4 (MEDIUM, 2026-06-01): invoiceType wurde an manchen
// Endpunkten nicht gegen die Enum-Whitelist validiert; ein beliebiger
// String landete als invoiceType in der DB und konnte Frontend-
// Filter/Reports verwirren oder XSS in Audit-Labels einschleusen.
type ValidInvoiceType = 'INTERIM' | 'FINAL' | 'NOT_AVAILABLE';
const VALID_INVOICE_TYPES: Set<ValidInvoiceType> = new Set(['INTERIM', 'FINAL', 'NOT_AVAILABLE']);
function assertValidInvoiceType(value: unknown, res: Response): value is ValidInvoiceType {
if (typeof value !== 'string' || !VALID_INVOICE_TYPES.has(value as ValidInvoiceType)) {
res.status(400).json({
success: false,
error: `Ungültiger Rechnungstyp. Erlaubt: ${[...VALID_INVOICE_TYPES].join(', ')}.`,
} as ApiResponse);
return false;
}
return true;
}
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
@@ -66,6 +83,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
} as ApiResponse);
return;
}
if (!assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.addInvoice(ecdId, {
invoiceDate: new Date(invoiceDate),
@@ -99,6 +117,8 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
// 56.4: invoiceType ist beim Update optional nur prüfen wenn gesetzt.
if (invoiceType !== undefined && !assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
@@ -168,6 +188,14 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
if (!invoiceDate || !invoiceType) {
res.status(400).json({
success: false,
error: 'invoiceDate und invoiceType sind erforderlich',
} as ApiResponse);
return;
}
if (!assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.addInvoiceByContract(contractId, {
invoiceDate: new Date(invoiceDate),
invoiceType,
@@ -3,10 +3,33 @@ import * as stressfreiEmailService from '../services/stressfreiEmail.service.js'
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.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.
//
// Pentest 77.3 (LOW): `Number.isInteger(parseInt(...))` ließ Floats
// und Exponential-Notation durch `4.0`, `4.5`, `4e1` werden alle
// zu `4` geparst und treffen die echte ID 4. Fix: erst gegen
// `/^\d+$/` validieren, dann erst parsen.
function requireIdParam(req: AuthRequest, res: Response, paramName: string): number | null {
const raw = req.params[paramName];
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
return null;
}
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> {
try {
const customerId = parseInt(req.params.customerId);
const customerId = requireIdParam(req, res, 'customerId');
if (customerId === null) return;
// requireCustomerAccess in der Route greift nicht ausreichend:
// Portal-User haben `customers:read` (für eigene Daten) und werden
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
@@ -26,7 +49,8 @@ export async function getEmailsByCustomer(req: AuthRequest, res: Response): Prom
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.getEmailById(emailId);
@@ -54,7 +78,8 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
export async function createEmail(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const customerId = requireIdParam(req, res, 'customerId');
if (customerId === null) return;
const email = await stressfreiEmailService.createEmail({
...req.body,
customerId,
@@ -67,7 +92,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
});
res.status(201).json({ success: true, data: email } as ApiResponse);
} catch (error) {
res.status(400).json({
const status = error instanceof ApiError ? error.statusCode : 400;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
} as ApiResponse);
@@ -76,7 +102,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
await logChange({
@@ -86,7 +113,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
});
res.json({ success: true, data: email } as ApiResponse);
} catch (error) {
res.status(400).json({
const status = error instanceof ApiError ? error.statusCode : 400;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
} as ApiResponse);
@@ -95,7 +123,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
await stressfreiEmailService.deleteEmail(emailId);
await logChange({
@@ -114,7 +143,8 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
@@ -151,9 +181,59 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
}
}
/**
* Zusätzliche Weiterleitungs-E-Mails der StressfreiEmail neu setzen.
* Body: `{ emails: string[] }`. Liste ersetzt komplett, Provider wird
* unmittelbar nachgezogen.
*/
export async function updateAdditionalForwards(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const body = req.body ?? {};
if (!Array.isArray(body.emails)) {
res.status(400).json({ success: false, error: '`emails` muss ein Array sein.' } as ApiResponse);
return;
}
if (body.emails.length > 20) {
res.status(400).json({ success: false, error: 'Maximal 20 zusätzliche Weiterleitungen erlaubt.' } as ApiResponse);
return;
}
const result = await stressfreiEmailService.setAdditionalForwards(emailId, body.emails);
if (!result.success) {
res.status(400).json({ success: false, error: result.error } as ApiResponse);
return;
}
await logChange({
req,
action: 'UPDATE',
resourceType: 'StressfreiEmail',
resourceId: emailId.toString(),
label: `Zusatz-Weiterleitungen aktualisiert (${(result.forwardTargets || []).length} Ziele aktiv)`,
});
res.json({
success: true,
data: { forwardTargets: result.forwardTargets },
message: 'Weiterleitungen aktualisiert',
} as ApiResponse);
} catch (error) {
const status = error instanceof ApiError ? error.statusCode : 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
} as ApiResponse);
}
}
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
if (!result.success) {
+30 -2
View File
@@ -4,7 +4,7 @@ import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Users
@@ -70,6 +70,20 @@ export async function createUser(req: Request, res: Response): Promise<void> {
return;
}
}
// 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern.
try {
if ('whatsappNumber' in data) {
const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer');
data.whatsappNumber = cleaned ?? null;
}
if ('signalNumber' in data) {
const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer');
data.signalNumber = cleaned ?? null;
}
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
return;
}
const user = await userService.createUser(data);
await logChange({
req, action: 'CREATE', resourceType: 'User',
@@ -114,13 +128,27 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
return;
}
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body);
const data = pickUserUpdate(req.body) as Record<string, unknown>;
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
if (data?.email !== undefined && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
// 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern.
try {
if ('whatsappNumber' in data) {
const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer');
data.whatsappNumber = cleaned ?? null;
}
if ('signalNumber' in data) {
const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer');
data.signalNumber = cleaned ?? null;
}
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
+11
View File
@@ -253,6 +253,17 @@ app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
return (downloadFile as any)(req, res, next);
});
// Pentest 55.3 (HIGH, 2026-06-01): /uploads/contract-documents/*.pdf
// kam ungeschützt durch, weil der nginx-Reverse-Proxy die Dateien
// direkt aus dem Filesystem auslieferte und der Backend-Auth-Check
// nur bei /api/uploads/* griff. Defense-in-Depth: dieselbe Route auch
// ohne /api-Präfix freischalten damit der Backend-Owner-Check immer
// läuft, egal wie nginx konfiguriert ist.
app.get('/uploads/*', authenticate as any, (req, res, next) => {
req.query.path = req.originalUrl.split('?')[0];
return (downloadFile as any)(req, res, next);
});
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
+34
View File
@@ -0,0 +1,34 @@
import { Request, Response, NextFunction } from 'express';
import fs from 'fs';
import { assertSafePdf } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
/**
* Strikte Variante: Datei MUSS eine PDF sein. Sonst 415. Für Routen, die
* ausschliesslich PDFs zulassen (z.B. Vollmacht-Upload, PDF-Templates).
*
* Routen, die auch JPG/PNG akzeptieren (z.B. contract.routes
* Vertragsdokumente), nutzen `validateUploadedFile` aus
* `uploadFileTypeValidator.ts` das macht Magic-Byte für ALLE Typen +
* PDF-Scan in einer Pipeline.
*/
export function requireSafeUploadedPdf(req: Request, res: Response, next: NextFunction): void {
const file = (req as Request & { file?: Express.Multer.File }).file;
if (!file) {
next();
return;
}
try {
const buf = fs.readFileSync(file.path);
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
throw new ApiError(415, 'Datei ist keine gültige PDF.');
}
assertSafePdf(buf);
next();
} catch (e) {
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
const status = e instanceof ApiError ? e.statusCode : 415;
const message = e instanceof Error ? e.message : 'PDF ungültig';
res.status(status).json({ success: false, error: message });
}
}
@@ -0,0 +1,98 @@
import { Request, Response, NextFunction } from 'express';
import fs from 'fs';
import path from 'path';
import { assertSafePdf } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
/**
* Magic-Byte-Whitelist + canonical Extension Rename + PDF-Active-Content-
* Scan in einer Middleware. Greift nach `multer.single(...)`, prüft die
* geschriebene Datei auf erlaubten Typ (PDF/JPG/PNG/GIF/WebP) und benennt
* sie auf eine kanonische Endung um damit verschwindet die
* `evil.gif.php`-Doppel-Endung und der client-gemeldete mimetype wird
* durch den ERKANNTEN ersetzt.
*
* Historie:
* - Pentest 39.3 / 39.4 (2026-05-30): Magic-Byte + canonical Rename.
* - Pentest 68.1 (2026-06-03): PDF-Body-Scan auf JS/Launch/Embed/RichMedia.
* - Pentest 69.3 (2026-06-03): Wiederverwendung in contract.routes.ts
* (Vertragsdokumente) vorher waren JPG/PNG-Uploads dort ungeprüft,
* nur durch Download-Layer kompensiert.
*/
const PDF_MAGIC = Buffer.from('%PDF-', 'latin1');
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const GIF87 = Buffer.from('GIF87a', 'latin1');
const GIF89 = Buffer.from('GIF89a', 'latin1');
export function detectFileType(buf: Buffer): { mime: string; ext: string } | null {
if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' };
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' };
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' };
if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' };
if (buf.length >= 12
&& buf.subarray(0, 4).toString('latin1') === 'RIFF'
&& buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' };
return null;
}
export function validateUploadedFile(req: Request, res: Response, next: NextFunction): void {
const file = (req as Request & { file?: Express.Multer.File }).file;
if (!file) {
next();
return;
}
try {
const fd = fs.openSync(file.path, 'r');
const head = Buffer.alloc(12);
fs.readSync(fd, head, 0, 12, 0);
fs.closeSync(fd);
const detected = detectFileType(head);
if (!detected) {
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
res.status(415).json({
success: false,
error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).',
});
return;
}
if (detected.mime === 'application/pdf') {
try {
const fullBuf = fs.readFileSync(file.path);
assertSafePdf(fullBuf);
} catch (e) {
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
const status = e instanceof ApiError ? e.statusCode : 415;
const msg = e instanceof Error ? e.message : 'PDF ungültig';
res.status(status).json({ success: false, error: msg });
return;
}
}
const dir = path.dirname(file.path);
const base = path.basename(file.path).replace(/\.[^./]+(\.[^./]+)*$/, '');
const newName = base + detected.ext;
const newPath = path.join(dir, newName);
if (newPath !== file.path) {
try {
fs.renameSync(file.path, newPath);
file.path = newPath;
file.filename = newName;
} catch (e) {
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
console.error('Upload-Rename fehlgeschlagen:', e);
res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' });
return;
}
}
file.mimetype = detected.mime;
next();
} catch (e) {
console.error('Magic-Byte-Check fehlgeschlagen:', e);
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' });
}
}
+9 -4
View File
@@ -5,6 +5,7 @@ import fs from 'fs';
import * as contractController from '../controllers/contract.controller.js';
import * as invoiceController from '../controllers/invoice.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
const router = Router();
@@ -22,11 +23,15 @@ const docUpload = multer({
},
}),
fileFilter: (_req, file, cb) => {
const allowed = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
// Pentest 70.1: aligned mit validateUploadedFile-Whitelist
// (PDF/JPG/PNG/GIF/WebP). Multer-fileFilter ist nur "fast reject"
// anhand des client-gemeldeten MIME-Types; der echte Guard ist der
// Magic-Byte-Check in validateUploadedFile.
const allowed = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (allowed.includes(file.mimetype)) cb(null, true);
else cb(new Error('Nur PDF, JPG und PNG Dateien sind erlaubt'));
else cb(new Error('Nur PDF, JPG, PNG, GIF oder WebP-Dateien sind erlaubt'));
},
limits: { fileSize: 10 * 1024 * 1024 },
limits: { fileSize: 25 * 1024 * 1024 },
});
router.get('/', authenticate, requirePermission('contracts:read'), contractController.getContracts);
@@ -54,7 +59,7 @@ router.post('/:id/invoices', authenticate, requirePermission('contracts:update')
// Vertragsdokumente
router.get('/:id/documents', authenticate, requirePermission('contracts:read'), contractController.getContractDocuments);
router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), contractController.uploadContractDocument);
router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), validateUploadedFile, contractController.uploadContractDocument);
router.delete('/:id/documents/:documentId', authenticate, requirePermission('contracts:update'), contractController.deleteContractDocument);
// Folgezähler
+3 -2
View File
@@ -3,6 +3,7 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { requireSafeUploadedPdf } from '../middleware/pdfUploadSafety.js';
import * as gdprController from '../controllers/gdpr.controller.js';
const router = Router();
@@ -27,7 +28,7 @@ const authUpload = multer({
cb(new Error('Nur PDF-Dateien sind erlaubt'));
}
},
limits: { fileSize: 10 * 1024 * 1024 },
limits: { fileSize: 25 * 1024 * 1024 },
});
// Alle Routen erfordern Authentifizierung
@@ -84,7 +85,7 @@ router.get('/customer/:customerId/authorizations', requirePermission('customers:
router.post('/customer/:customerId/authorizations/:representativeId/send', requirePermission('customers:update'), gdprController.sendAuthorizationRequest);
router.post('/customer/:customerId/authorizations/:representativeId/grant', requirePermission('customers:update'), gdprController.grantAuthorization);
router.post('/customer/:customerId/authorizations/:representativeId/withdraw', requirePermission('customers:update'), gdprController.withdrawAuthorization);
router.post('/customer/:customerId/authorizations/:representativeId/upload', requirePermission('customers:update'), authUpload.single('document'), gdprController.uploadAuthorizationDocument);
router.post('/customer/:customerId/authorizations/:representativeId/upload', requirePermission('customers:update'), authUpload.single('document'), requireSafeUploadedPdf, gdprController.uploadAuthorizationDocument);
router.delete('/customer/:customerId/authorizations/:representativeId/document', requirePermission('customers:update'), gdprController.deleteAuthorizationDocument);
// Portal: Vollmachten
+2 -1
View File
@@ -3,6 +3,7 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { requireSafeUploadedPdf } from '../middleware/pdfUploadSafety.js';
import * as pdfTemplateController from '../controllers/pdfTemplate.controller.js';
const router = Router();
@@ -34,7 +35,7 @@ router.use(authenticate);
router.get('/', requirePermission('settings:read'), pdfTemplateController.getTemplates);
router.get('/crm-fields', requirePermission('settings:read'), pdfTemplateController.getCrmFields);
router.get('/:id', requirePermission('settings:read'), pdfTemplateController.getTemplate);
router.post('/', requirePermission('settings:update'), upload.single('template'), pdfTemplateController.createTemplate);
router.post('/', requirePermission('settings:update'), upload.single('template'), requireSafeUploadedPdf, pdfTemplateController.createTemplate);
router.put('/:id', requirePermission('settings:update'), pdfTemplateController.updateTemplate);
router.delete('/:id', requirePermission('settings:update'), pdfTemplateController.deleteTemplate);
@@ -15,4 +15,8 @@ router.post('/:id/reset-password', authenticate, requirePermission('customers:up
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
// Zusätzliche Weiterleitungs-Ziele setzen (User-pflegbare Liste, zusätzlich
// zur Stamm-E-Mail des Kunden und der globalen Default-Forward-Adresse).
router.put('/:id/additional-forwards', authenticate, requirePermission('customers:update'), stressfreiEmailController.updateAdditionalForwards);
export default router;
+110 -88
View File
@@ -6,7 +6,33 @@ import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
import {
canAccessContract,
canAccessCustomer,
canAccessBankCard,
canAccessIdentityDocument,
} from '../utils/accessControl.js';
import { validateOptionalIsoDate } from '../utils/sanitize.js';
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
// für saubere 404-Antwort + Datei-Cleanup, wenn die Resource nicht
// existiert. Anschließend laufen die `canAccess*`-Checks (Portal-User
// werden dort auf ihre eigenen Kunden eingeschränkt; Staff bekommen
// volle Sicht konsistent mit der bestehenden Access-Control-Logik).
async function resolveInvoiceContractId(invoiceId: number): Promise<number | null> {
const invoice = await prisma.invoice.findUnique({
where: { id: invoiceId },
select: { contractId: true, energyContractDetails: { select: { contractId: true } } },
});
return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null;
}
function cleanupFile(filePath?: string) {
if (!filePath) return;
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
const router = Router();
@@ -57,7 +83,10 @@ const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
// 25 MB passt für Ausweis-Scans, Handy-Photos im JpgToPdf-Flow,
// mehrseitige PDFs aus dem Modal (bis ~5-7 Seiten je nach Auflösung).
// Vorher 10 MB → Multer brach bei zwei Smartphone-Fotos ab.
fileSize: 25 * 1024 * 1024,
},
});
@@ -69,84 +98,9 @@ function setUploadDir(subDir: string) {
};
}
/**
* Post-Upload-Validierung: prüft die Magic-Bytes der gerade geschriebenen
* Datei und vergleicht mit dem fileFilter-Whitelist. Bei Mismatch
* (Pentest 2026-05-30 LOW 39.3: WebP/GIF/JPG/PDF-Spoofing) wird die
* Datei sofort gelöscht + 415 zurück.
*
* Zusätzlich (39.4): die Datei wird auf eine kanonische Endung umbenannt,
* die aus dem ERKANNTEN Typ abgeleitet ist nicht aus dem
* client-gemeldeten file.originalname. Damit verschwindet die
* `evil.gif.php`-Doppel-Endung; gespeicherter Name ist
* `<timestamp-random>.<canonical-ext>` (z.B. `.pdf` / `.png`).
*/
const PDF_MAGIC = Buffer.from('%PDF-', 'latin1');
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const GIF87 = Buffer.from('GIF87a', 'latin1');
const GIF89 = Buffer.from('GIF89a', 'latin1');
function detectType(buf: Buffer): { mime: string; ext: string } | null {
if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' };
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' };
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' };
if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' };
if (buf.length >= 12
&& buf.subarray(0, 4).toString('latin1') === 'RIFF'
&& buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' };
return null;
}
function validateUploadedFile(req: AuthRequest, res: Response, next: Function) {
if (!req.file) return next();
try {
const fd = fs.openSync(req.file.path, 'r');
const head = Buffer.alloc(12);
fs.readSync(fd, head, 0, 12, 0);
fs.closeSync(fd);
const detected = detectType(head);
if (!detected) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(415).json({
success: false,
error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).',
});
return;
}
// Filename auf kanonische Extension normalisieren. Multer hat
// `<unique>.gif.php` o.ä. geschrieben wir wollen `<unique>.gif`.
const dir = path.dirname(req.file.path);
const base = path.basename(req.file.path).replace(/\.[^./]+(\.[^./]+)*$/, '');
const newName = base + detected.ext;
const newPath = path.join(dir, newName);
if (newPath !== req.file.path) {
try {
fs.renameSync(req.file.path, newPath);
req.file.path = newPath;
req.file.filename = newName;
} catch (e) {
// Rename hat seltene Edge-Cases (Cross-Device). Sicherheit
// geht vor sollte das fehlschlagen, werfen wir lieber 500
// und putzen die alte Datei.
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
console.error('Upload-Rename fehlgeschlagen:', e);
res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' });
return;
}
}
// Mimetype mit dem ERKANNTEN überschreiben, damit die Controller
// den korrekten Typ persistieren (falls sie ihn weiterreichen).
req.file.mimetype = detected.mime;
next();
} catch (e) {
console.error('Magic-Byte-Check fehlgeschlagen:', e);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' });
}
}
// Magic-Byte-Whitelist + canonical Rename + PDF-Scan: siehe
// middleware/uploadFileTypeValidator.ts (zentralisiert, damit auch
// contract.routes.ts denselben Check fahren kann Pentest 69.3).
// Upload für Bankkarten-Dokumente
router.post(
@@ -164,9 +118,20 @@ router.post(
}
const bankCardId = parseInt(req.params.id);
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
if (!card) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
return;
}
if (!(await canAccessBankCard(req, res, bankCardId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
// Bankkarte in der DB aktualisieren
await prisma.bankCard.update({
where: { id: bankCardId },
data: { documentPath: relativePath },
@@ -183,6 +148,7 @@ router.post(
});
} catch (error) {
console.error('Upload error:', error);
cleanupFile(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
@@ -204,9 +170,20 @@ router.post(
}
const documentId = parseInt(req.params.id);
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
if (!doc) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
return;
}
if (!(await canAccessIdentityDocument(req, res, documentId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/documents/${req.file.filename}`;
// Ausweis in der DB aktualisieren
await prisma.identityDocument.update({
where: { id: documentId },
data: { documentPath: relativePath },
@@ -223,6 +200,7 @@ router.post(
});
} catch (error) {
console.error('Upload error:', error);
cleanupFile(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
@@ -246,6 +224,8 @@ router.delete(
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
return;
}
// Pentest 56.1: Ownership-Check.
if (!(await canAccessBankCard(req, res, bankCardId))) return;
if (!bankCard.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
@@ -290,6 +270,8 @@ router.delete(
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
return;
}
// Pentest 56.1: Ownership-Check.
if (!(await canAccessIdentityDocument(req, res, documentId))) return;
if (!document.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
@@ -334,6 +316,11 @@ router.post(
}
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
@@ -383,6 +370,11 @@ router.post(
}
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
@@ -424,6 +416,8 @@ router.delete(
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
@@ -464,6 +458,8 @@ router.delete(
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
@@ -514,6 +510,14 @@ router.post(
}
const customerId = parseInt(req.params.id);
// Pentest 56.2 (HIGH): Ownership-Check VOR Consent-Massen-Update.
// Ohne diese Prüfung konnte jeder Caller mit customers:update für
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
// (DSGVO-Eskalation).
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
@@ -574,6 +578,8 @@ router.delete(
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
@@ -662,11 +668,18 @@ async function handleContractDocumentUpload(
const dateField = fieldName === 'cancellationConfirmationPath'
? 'cancellationConfirmationDate'
: 'cancellationConfirmationOptionsDate';
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
// Pentest 62.7: confirmationDate gegen ISO-8601 validieren.
let provided: string | null;
try {
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
} catch (err) {
cleanupFile(req.file?.path);
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' });
return;
}
let target: Date | null = null;
if (provided) {
const parsed = new Date(provided);
if (!isNaN(parsed.getTime())) target = parsed;
target = new Date(provided);
}
if (target) {
updateData[dateField] = target;
@@ -846,14 +859,20 @@ router.post(
}
const invoiceId = parseInt(req.params.id);
const relativePath = `/uploads/invoices/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
if (!invoice) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return;
}
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/invoices/${req.file.filename}`;
if (invoice.documentPath) {
const oldPath = path.join(process.cwd(), invoice.documentPath);
@@ -898,6 +917,9 @@ router.delete(
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return;
}
// Pentest 56.1: Ownership-Check vor Delete.
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) return;
if (!invoice.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
+7 -1
View File
@@ -593,7 +593,13 @@ function generateResetToken(): string {
* Hat Trailing-Slash-Bereinigung, sonst kommen Links wie
* `https://crm.de//portal/login` zustande.
*/
async function getPublicUrl(): Promise<string> {
// Pentest 59.4 Nebenbefund (2026-06-01): Consent-URL kam mit
// `localhost:5173` raus, weil PUBLIC_URL nicht gesetzt war und
// req.headers.origin im Hintergrund-Pfad nicht greift. Helper jetzt
// EXPORT, damit auch der GDPR-Controller (sendConsentLink etc.)
// dieselbe Quelle der Wahrheit nutzt inklusive admin-konfigurierbarer
// portalLoginUrl App-Setting.
export async function getPublicUrl(): Promise<string> {
const fromSettings = await appSettingService.getSetting('portalLoginUrl');
const raw = (fromSettings && fromSettings.trim())
|| process.env.PUBLIC_URL
@@ -1,6 +1,7 @@
import prisma from '../lib/prisma.js';
import fs from 'fs';
import path from 'path';
import { assertValidDocumentPath } from '../utils/sanitize.js';
/**
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
@@ -53,6 +54,8 @@ export async function grantAuthorization(
representativeId: number,
data: { source?: string; documentPath?: string; notes?: string }
) {
// Pentest 26.7 (Defense-in-Depth): documentPath nur als /uploads/<safe>.
assertValidDocumentPath(data.documentPath, 'documentPath');
return prisma.representativeAuthorization.upsert({
where: {
customerId_representativeId: { customerId, representativeId },
+36 -5
View File
@@ -1040,20 +1040,41 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
const finalBackupName = path.basename(finalBackupDir);
// ZIP entpacken mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
// Pentest 57.8 (2026-06-01): Mehrstufige Verteidigung gegen Path-
// Traversal-Varianten, die `path.resolve` allein eventuell durchlässt
// (z.B. Backslash-Mischformen auf Cross-OS, Null-Bytes, leere Namen,
// explizite `..`-Segmente). Plus Zip-Bomb-Schutz per Entry-Größenlimit.
const absBackupDir = path.resolve(finalBackupDir);
fs.mkdirSync(absBackupDir, { recursive: true });
const MAX_ENTRY_SIZE = 500 * 1024 * 1024; // 500 MB pro Entry
let totalUncompressed = 0;
const MAX_TOTAL_UNCOMPRESSED = 5 * 1024 * 1024 * 1024; // 5 GB Gesamt
for (const entry of entries) {
// Pfade mit absoluten Pfaden oder Traversal ablehnen
const entryName = entry.entryName;
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
// Reject: leer, Null-Byte, absoluter Pfad, Backslashes (Cross-OS-
// Confusion), expliziter `..`-Segment im Original-Namen,
// Home-Dir-Expansion `~/`.
if (
!entryName
|| entryName.includes('\0')
|| entryName.includes('\\')
|| entryName.startsWith('~')
|| path.isAbsolute(entryName)
|| entryName.split('/').some((seg) => seg === '..')
) {
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
}
const targetPath = path.resolve(absBackupDir, entryName);
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
// Zip-Slip-Check: aufgelöster Pfad muss strikt im Backup-Verzeichnis
// liegen. path.relative gibt "../..." zurück wenn target außerhalb
// liegt das ist robuster als startsWith + Separator-Concat.
const rel = path.relative(absBackupDir, targetPath);
if (rel === '' && !entry.isDirectory) {
return { success: false, error: `Datei-Eintrag zeigt auf das Backup-Wurzelverzeichnis` };
}
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return {
success: false,
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
@@ -1063,6 +1084,16 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
if (entry.isDirectory) {
fs.mkdirSync(targetPath, { recursive: true });
} else {
// Zip-Bomb-Schutz: Entry-Größe begrenzen und Gesamt-Tracking
if (typeof entry.header?.size === 'number') {
if (entry.header.size > MAX_ENTRY_SIZE) {
return { success: false, error: `Eintrag "${entryName}" überschreitet das Größenlimit von 500 MB` };
}
totalUncompressed += entry.header.size;
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
return { success: false, error: `Backup-ZIP überschreitet das entpackte Gesamtlimit von 5 GB (Zip-Bomb-Schutz)` };
}
}
// Zielverzeichnis sicherstellen
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
// Datei schreiben
+57 -37
View File
@@ -603,33 +603,35 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
}
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails)
export async function getFolderCountsForContract(contractId: number): Promise<{
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
// Optional auf ein bestimmtes Postfach einschränken. Bug 2026-06-21:
// vorher zählten die Badges Mails aus ALLEN Postfächern, während die
// Liste (nach Fix) nur die des ausgewählten Postfachs zeigt Badge
// und Liste liefen auseinander. Trash mit reingenommen, weil der
// Contract-Trash-Badge sonst wieder auf account-globalen Zähler
// zurückfallen müsste.
export async function getFolderCountsForContract(
contractId: number,
stressfreiEmailId?: number,
): Promise<{
inbox: number;
inboxUnread: number;
sent: number;
sentUnread: number;
trash: number;
trashUnread: number;
}> {
const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([
// INBOX total
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false },
}),
// INBOX unread
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false },
}),
// SENT total
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.SENT, isDeleted: false },
}),
// SENT unread
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.SENT, isDeleted: false, isRead: false },
}),
const baseWhere: Prisma.CachedEmailWhereInput = { contractId };
if (stressfreiEmailId) baseWhere.stressfreiEmailId = stressfreiEmailId;
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false } }),
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false, isRead: false } }),
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false } }),
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false, isRead: false } }),
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true } }),
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true, isRead: false } }),
]);
return { inbox, inboxUnread, sent, sentUnread };
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
}
// Alle StressfreiEmails eines Kunden mit Mailbox
@@ -904,14 +906,26 @@ export async function permanentDeleteEmail(id: number): Promise<TrashOperationRe
}
// Papierkorb-E-Mails für einen Kunden abrufen
export async function getTrashEmails(customerId: number): Promise<CachedEmailWithRelations[]> {
return prisma.cachedEmail.findMany({
where: {
// Optional: nach Postfach (stressfreiEmailId) und/oder Vertrag (contractId)
// einschränken. Vorher zeigte der Papierkorb immer ALLE gelöschten E-Mails
// des Kunden, unabhängig von welchem Postfach man gerade angemeldet ist
// User-Bug 2026-06-21.
export async function getTrashEmails(
customerId: number,
options?: { stressfreiEmailId?: number; contractId?: number },
): Promise<CachedEmailWithRelations[]> {
const where: Prisma.CachedEmailWhereInput = {
isDeleted: true,
stressfreiEmail: {
customerId,
},
},
stressfreiEmail: { customerId },
};
if (options?.stressfreiEmailId) {
where.stressfreiEmailId = options.stressfreiEmailId;
}
if (options?.contractId) {
where.contractId = options.contractId;
}
return prisma.cachedEmail.findMany({
where,
include: {
stressfreiEmail: {
select: {
@@ -931,16 +945,22 @@ export async function getTrashEmails(customerId: number): Promise<CachedEmailWit
}) as Promise<CachedEmailWithRelations[]>;
}
// Papierkorb-E-Mails zählen
export async function getTrashCount(customerId: number): Promise<number> {
return prisma.cachedEmail.count({
where: {
// Papierkorb-E-Mails zählen (gleiche Filter wie getTrashEmails)
export async function getTrashCount(
customerId: number,
options?: { stressfreiEmailId?: number; contractId?: number },
): Promise<number> {
const where: Prisma.CachedEmailWhereInput = {
isDeleted: true,
stressfreiEmail: {
customerId,
},
},
});
stressfreiEmail: { customerId },
};
if (options?.stressfreiEmailId) {
where.stressfreiEmailId = options.stressfreiEmailId;
}
if (options?.contractId) {
where.contractId = options.contractId;
}
return prisma.cachedEmail.count({ where });
}
// Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
+39 -9
View File
@@ -5,8 +5,19 @@ import * as consentService from './consent.service.js';
import * as appSettingService from './appSetting.service.js';
import PDFDocument from 'pdfkit';
// Pentest 57.7 (MEDIUM, 2026-06-01): Public-Consent-Hashes hatten keine
// Ablauffrist. Ein versehentlich weitergegebener oder geleakter Link
// hätte Jahre später noch fremde Einwilligungen erteilen können
// (DSGVO-Pflicht zur Zweckbindung). 30 Tage ist der Default-Zeitraum,
// in dem ein Kunde realistisch auf den Versandlink klickt; danach muss
// ein Mitarbeiter den Link neu generieren (ensureConsentHash() erzeugt
// einen neuen Hash + neue Frist).
const CONSENT_HASH_TTL_DAYS = 30;
/**
* Kunden-Lookup per consentHash
* Kunden-Lookup per consentHash. Liefert null wenn der Hash unbekannt
* oder abgelaufen ist aus Sicht des Aufrufers identisch, damit der
* Public-Endpoint keine Unterscheidung "ungültig vs. abgelaufen" leakt.
*/
export async function getCustomerByConsentHash(hash: string) {
const customer = await prisma.customer.findUnique({
@@ -18,28 +29,40 @@ export async function getCustomerByConsentHash(hash: string) {
customerNumber: true,
salutation: true,
email: true,
consentHashExpiresAt: true,
},
});
if (!customer) return null;
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
return null;
}
const consents = await consentService.getCustomerConsents(customer.id);
return { customer, consents };
// consentHashExpiresAt nicht an den Client durchreichen
const { consentHashExpiresAt: _expires, ...customerWithoutExpiry } = customer;
void _expires;
return { customer: customerWithoutExpiry, consents };
}
/**
* Alle 4 Einwilligungen über den öffentlichen Link erteilen
* Alle 4 Einwilligungen über den öffentlichen Link erteilen.
* Wirft bei abgelaufenem oder unbekanntem Hash mit gleicher Meldung,
* damit kein Oracle "existiert vs. abgelaufen" entsteht.
*/
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
const customer = await prisma.customer.findUnique({
where: { consentHash: hash },
select: { id: true, firstName: true, lastName: true },
select: { id: true, firstName: true, lastName: true, consentHashExpiresAt: true },
});
if (!customer) {
throw new Error('Ungültiger Link');
}
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
throw new Error('Link ist abgelaufen. Bitte einen neuen Link anfordern.');
}
const results = [];
for (const type of Object.values(ConsentType)) {
@@ -56,26 +79,33 @@ export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
}
/**
* consentHash generieren falls nicht vorhanden
* consentHash generieren oder erneuern. Liefert einen bestehenden Hash
* nur zurück, wenn dessen TTL noch nicht abgelaufen ist sonst wird ein
* neuer Hash + neue Frist gesetzt (Pentest 57.7).
*/
export async function ensureConsentHash(customerId: number): Promise<string> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { consentHash: true },
select: { consentHash: true, consentHashExpiresAt: true },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
if (customer.consentHash) {
return customer.consentHash;
const stillValid = customer.consentHash
&& customer.consentHashExpiresAt
&& customer.consentHashExpiresAt.getTime() > Date.now();
if (stillValid) {
return customer.consentHash!;
}
const hash = crypto.randomUUID();
const expiresAt = new Date(Date.now() + CONSENT_HASH_TTL_DAYS * 24 * 60 * 60 * 1000);
await prisma.customer.update({
where: { id: customerId },
data: { consentHash: hash },
data: { consentHash: hash, consentHashExpiresAt: expiresAt },
});
return hash;
+7
View File
@@ -2,6 +2,7 @@ import { ConsentType, ConsentStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import fs from 'fs';
import path from 'path';
import { assertValidDocumentPath } from '../utils/sanitize.js';
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
@@ -80,6 +81,12 @@ export async function updateConsent(
throw new Error('Kunde nicht gefunden');
}
// Pentest 26.7: documentPath darf nur ein gültiger /uploads/<safe>-Pfad
// sein. Aktuell hat KEIN Endpoint diesen Wert aus User-Input gemappt
// (Portal: nicht aus Body, Admin-Auth-Upload: server-generated). Diese
// Service-Side-Validation ist Defense-in-Depth gegen zukünftige Caller.
assertValidDocumentPath(data.documentPath, 'documentPath');
const now = new Date();
const updateData = {
status: data.status,
+14 -19
View File
@@ -2,25 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sanitizeCustomerStrict } from '../utils/sanitize.js';
// Pentest 51.3 (LOW, 2026-06-01): Telefon-/Vorwahl-Felder dürfen NIE CRLF
// oder andere Control-Chars enthalten sonst könnten sie über Header-
// Injection (Mail, HTTP) missbraucht werden, wenn der Wert mal in einen
// Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf
// literales Space beschränkt, NICHT `\s` das matched sonst `\r\n\t`
// und macht den Schutz wirkungslos. Allowed: Ziffern, Plus, Minus, Slash,
// Klammern, Punkt, einfaches Leerzeichen. Bis 40 Zeichen.
const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/;
function sanitizePhoneField(raw: string | null | undefined, fieldLabel: string): string | undefined {
if (raw == null) return undefined;
const trimmed = String(raw).trim();
if (trimmed === '') return undefined;
if (!PHONE_FIELD_ALLOWED.test(trimmed)) {
throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`);
}
return trimmed;
}
import { sanitizeCustomerStrict, sanitizePhoneField } from '../utils/sanitize.js';
export interface ContractFilters {
customerId?: number;
@@ -221,6 +203,9 @@ interface ContractCreateData {
providerName?: string;
tariffName?: string;
customerNumberAtProvider?: string;
orderNumberAtSalesPlatform?: string;
customerNumberAtSalesPlatform?: string;
contractNumberAtSalesPlatform?: string;
priceFirst12Months?: string;
priceFrom13Months?: string;
priceAfter24Months?: string;
@@ -294,6 +279,7 @@ interface ContractCreateData {
puk?: string;
isMultisim?: boolean;
isMain?: boolean;
isEsim?: boolean;
cardUser?: string;
}[];
};
@@ -399,6 +385,7 @@ export async function createContract(data: ContractCreateData) {
puk: sc.puk ? encrypt(sc.puk) : undefined,
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
isEsim: sc.isEsim ?? false,
cardUser: sc.cardUser,
})),
}
@@ -638,6 +625,7 @@ export async function updateContract(
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
isEsim: sc.isEsim ?? false,
cardUser: sc.cardUser,
};
}),
@@ -657,6 +645,7 @@ export async function updateContract(
puk: sc.puk ? encrypt(sc.puk) : undefined,
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
isEsim: sc.isEsim ?? false,
cardUser: sc.cardUser,
})),
}
@@ -784,6 +773,7 @@ export async function createFollowUpContract(previousContractId: number) {
simCardNumber: sc.simCardNumber ?? undefined,
isMultisim: sc.isMultisim,
isMain: sc.isMain,
isEsim: sc.isEsim,
})),
};
}
@@ -909,6 +899,10 @@ export async function createRenewalContract(previousContractId: number) {
providerName: previousContract.providerName,
tariffName: previousContract.tariffName,
customerNumberAtProvider: previousContract.customerNumberAtProvider,
contractNumberAtProvider: previousContract.contractNumberAtProvider,
orderNumberAtSalesPlatform: previousContract.orderNumberAtSalesPlatform,
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
portalUsername: previousContract.portalUsername,
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
commission: previousContract.commission,
@@ -1008,6 +1002,7 @@ export async function createRenewalContract(previousContractId: number) {
simCardNumber: sc.simCardNumber,
isMultisim: sc.isMultisim,
isMain: sc.isMain,
isEsim: sc.isEsim,
pin: sc.pin,
puk: sc.puk,
},
@@ -10,6 +10,7 @@
import cron from 'node-cron';
import prisma from '../lib/prisma.js';
import { createAuditLog, logChange } from './audit.service.js';
import { ApiError } from '../utils/apiError.js';
async function runExpireCheck(): Promise<void> {
const today = new Date();
@@ -84,6 +85,61 @@ export function startContractStatusScheduler(): void {
export { runExpireCheck };
/**
* Pentest 55.4 (LOW, 2026-06-01): 5 parallele Lieferbestätigung-Requests
* erzeugten 5 ContractDocuments. Application-Lock per (contractId,
* documentType) verhindert das in der Praxis (single-instance) und bietet
* für Cluster wenigstens eine deutliche Verzögerung gegen Spam-Sprays.
*
* Plus DB-Check „kürzlich angelegt": rejected, falls innerhalb der
* letzten 10 s schon ein Eintrag mit gleichem Typ existiert. Schließt
* den größten Teil des Race-Windows und unterscheidet Spam-Attacks von
* legitimen Sekunden-später-Updates.
*/
const docCreateLocks = new Map<string, Promise<void>>();
export async function assertNoRecentDuplicateDocument(
contractId: number,
documentType: string,
): Promise<void> {
const recent = await prisma.contractDocument.findFirst({
where: {
contractId,
documentType,
createdAt: { gte: new Date(Date.now() - 10_000) },
},
select: { id: true },
});
if (recent) {
// Pentest 64.1: ApiError(400) statt generischem Error Caller
// mappt das auf 400 Bad Request statt pauschal 500.
throw new ApiError(400, 'Ein Dokument dieses Typs wurde vor wenigen Sekunden bereits angelegt bitte kurz warten und Seite neu laden.');
}
}
export async function withContractDocumentLock<T>(
contractId: number,
documentType: string,
fn: () => Promise<T>,
): Promise<T> {
const key = `${contractId}|${documentType.trim().toLowerCase()}`;
const previous = docCreateLocks.get(key);
let release: () => void = () => {};
const slot = new Promise<void>((resolve) => { release = resolve; });
docCreateLocks.set(key, (previous ?? Promise.resolve()).then(() => slot));
if (previous) await previous;
try {
await assertNoRecentDuplicateDocument(contractId, documentType);
return await fn();
} finally {
release();
// Map-Aufräumen: wenn niemand mehr in der Kette wartet
if (docCreateLocks.get(key) === (previous ?? Promise.resolve()).then(() => slot)) {
docCreateLocks.delete(key);
}
}
}
/**
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
* Lieferbestätigung ist:
@@ -180,17 +180,56 @@ export class PleskEmailProvider implements IEmailProvider {
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
let hasMailbox: boolean | undefined;
let mailgroupActive: boolean | undefined;
let mailgroupMembers: string[] | undefined;
let forwardingActive: boolean | undefined;
let forwardingTargets: string[] | undefined;
if (exists && result.stdout) {
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
if (mailboxMatch) {
hasMailbox = mailboxMatch[1].toLowerCase() === 'true';
}
// Mailgroup-Status + Mitglieder. Plesk listet sie auf einer
// Zeile, Adressen sind durch Whitespace getrennt.
const mailgroupMatch = result.stdout.match(/Mailgroup:\s*(true|false)/i);
if (mailgroupMatch) {
mailgroupActive = mailgroupMatch[1].toLowerCase() === 'true';
}
const groupMembersMatch = result.stdout.match(/Group member\(s\):\s*([^\n]*)/i);
if (groupMembersMatch) {
mailgroupMembers = groupMembersMatch[1]
.trim()
.split(/\s+/)
.filter((m) => m.includes('@'));
}
// Forwarding-Status + Ziele. Plesk druckt "Forward request: <addrs>".
// Auf manchen Plesk-Versionen heißt das Feld auch "Forwarding".
const forwardActiveMatch = result.stdout.match(/Forwarding:\s*(true|false)/i);
if (forwardActiveMatch) {
forwardingActive = forwardActiveMatch[1].toLowerCase() === 'true';
}
const forwardTargetsMatch = result.stdout.match(/Forward(?:ing)?(?: request)?:\s*([^\n]*)/i);
if (forwardTargetsMatch) {
forwardingTargets = forwardTargetsMatch[1]
.trim()
.split(/\s+/)
.filter((m) => m.includes('@'));
if (forwardingActive === undefined) {
forwardingActive = (forwardingTargets?.length ?? 0) > 0;
}
}
}
return {
exists,
email: exists ? email : undefined,
hasMailbox,
mailgroupActive,
mailgroupMembers,
forwardingActive,
forwardingTargets,
};
} catch (error) {
// HTTP-Fehler oder Netzwerkfehler
@@ -458,15 +497,63 @@ export class PleskEmailProvider implements IEmailProvider {
};
}
// Plesk CLI API: Weiterleitungsziele aktualisieren
// Format für -forwarding-addresses: "set:email1,email2" ersetzt alle Adressen
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
// Plesk-CLI-Eigenheit: `-forwarding-addresses` akzeptiert NUR
// `add:` und `del:`, KEIN `set:`. Und `-forwarding` ist der
// Mailgroup-Schalter (Plesk nennt das im `--info` "Mailgroup",
// im CLI "forwarding" derselbe Mechanismus, doppelt benannt).
// Es gibt KEINE separaten Mailgroup-Optionen wie `-mailgroup`.
//
// Wir bauen daher den Diff: alte Member abrufen, dann
// del:<entfernt> + add:<neu> in zwei separaten Calls. Idempotent,
// weil add: Duplikate ignoriert und del: nicht-vorhandene auch.
const currentMembers = exists.mailgroupMembers ?? [];
const targetsLower = new Set(targets.map((t) => t.toLowerCase()));
const currentLower = new Set(currentMembers.map((m) => m.toLowerCase()));
const toRemove = currentMembers.filter((m) => !targetsLower.has(m.toLowerCase()));
const toAdd = targets.filter((t) => !currentLower.has(t.toLowerCase()));
console.log(
`[Plesk updateForwardTargets] ${email} aktuell: [${currentMembers.join(', ')}], ` +
`soll: [${targets.join(', ')}], entfernen: [${toRemove.join(', ')}], hinzufügen: [${toAdd.join(', ')}]`,
);
// Entfernen-Schritt
if (toRemove.length > 0) {
const delParams = [
'--update', email,
'-forwarding-addresses', `del:${toRemove.join(',')}`,
];
const delResult = await this.request<{ code: number; stdout: string; stderr: string }>(
'POST', '/api/v2/cli/mail/call', { params: delParams },
);
console.log('[Plesk updateForwardTargets] del response:', JSON.stringify(delResult, null, 2));
if (delResult.code !== 0 || /error|failed/i.test(delResult.stderr || '')) {
return {
success: false,
error: delResult.stderr?.trim() || delResult.stdout?.trim() || `Plesk del returned code ${delResult.code}`,
};
}
}
// Hinzufügen-Schritt (impliziert -forwarding true, damit Mailgroup
// aktiviert bleibt bzw. wird).
if (toAdd.length > 0) {
const addParams = [
'--update', email,
'-forwarding', 'true',
'-forwarding-addresses', `set:${targets.join(',')}`,
],
});
'-forwarding-addresses', `add:${toAdd.join(',')}`,
];
const addResult = await this.request<{ code: number; stdout: string; stderr: string }>(
'POST', '/api/v2/cli/mail/call', { params: addParams },
);
console.log('[Plesk updateForwardTargets] add response:', JSON.stringify(addResult, null, 2));
if (addResult.code !== 0 || /error|failed/i.test(addResult.stderr || '')) {
return {
success: false,
error: addResult.stderr?.trim() || addResult.stdout?.trim() || `Plesk add returned code ${addResult.code}`,
};
}
}
return {
success: true,
@@ -42,6 +42,14 @@ export interface EmailExistsResult {
exists: boolean;
email?: string;
hasMailbox?: boolean; // true wenn echte Mailbox vorhanden
// Plesk hat zwei unabhängige Verteil-Mechanismen, beide können parallel
// aktiv sein. Manuelle/Legacy-Anlagen nutzen oft "Mailgroup" statt
// "Forwarding" unser Sync muss alte Mitglieder dort einsammeln,
// sonst gehen sie beim Umschalten auf Forwarding verloren.
mailgroupActive?: boolean;
mailgroupMembers?: string[];
forwardingActive?: boolean;
forwardingTargets?: string[];
}
export interface EmailOperationResult {
@@ -46,6 +46,13 @@ export interface ProviderExport {
portalUrl: string | null;
usernameFieldName: string | null;
passwordFieldName: string | null;
contactEmail: string | null;
contactPhone: string | null;
contactFax: string | null;
contactAddress: string | null;
cancellationEmail: string | null;
cancellationFax: string | null;
cancellationAddress: string | null;
isActive: boolean;
tariffs: { name: string; isActive: boolean }[];
}
@@ -90,6 +97,13 @@ export async function collectFactoryDefaults() {
portalUrl: p.portalUrl,
usernameFieldName: p.usernameFieldName,
passwordFieldName: p.passwordFieldName,
contactEmail: p.contactEmail,
contactPhone: p.contactPhone,
contactFax: p.contactFax,
contactAddress: p.contactAddress,
cancellationEmail: p.cancellationEmail,
cancellationFax: p.cancellationFax,
cancellationAddress: p.cancellationAddress,
isActive: p.isActive,
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
})),
@@ -284,6 +298,13 @@ export async function importFactoryDefaults(
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
contactEmail: p.contactEmail ?? null,
contactPhone: p.contactPhone ?? null,
contactFax: p.contactFax ?? null,
contactAddress: p.contactAddress ?? null,
cancellationEmail: p.cancellationEmail ?? null,
cancellationFax: p.cancellationFax ?? null,
cancellationAddress: p.cancellationAddress ?? null,
isActive: p.isActive ?? true,
},
create: {
@@ -291,6 +312,13 @@ export async function importFactoryDefaults(
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
contactEmail: p.contactEmail ?? null,
contactPhone: p.contactPhone ?? null,
contactFax: p.contactFax ?? null,
contactAddress: p.contactAddress ?? null,
cancellationEmail: p.cancellationEmail ?? null,
cancellationFax: p.cancellationFax ?? null,
cancellationAddress: p.cancellationAddress ?? null,
isActive: p.isActive ?? true,
},
});
+12 -2
View File
@@ -45,13 +45,16 @@ export const CRM_FIELDS = [
{ path: 'owner.phone', label: 'Telefon', group: 'Eigentümer' },
{ path: 'owner.mobile', label: 'Mobil', group: 'Eigentümer' },
{ path: 'owner.email', label: 'E-Mail', group: 'Eigentümer' },
// Rechnungsadresse
// Rechnungsadresse (Fallback auf Lieferadresse, wenn keine separate
// Rechnungsadresse gepflegt ist siehe billingAddress-Resolve im Code)
{ path: 'billingAddress.street', label: 'Straße (Rechnung)', group: 'Rechnungsadresse' },
{ path: 'billingAddress.houseNumber', label: 'Hausnummer (Rechnung)', group: 'Rechnungsadresse' },
{ path: 'billingAddress.streetFull', label: 'Straße + Nr. (Rechnung)', group: 'Rechnungsadresse' },
{ path: 'billingAddress.postalCode', label: 'PLZ (Rechnung)', group: 'Rechnungsadresse' },
{ path: 'billingAddress.city', label: 'Stadt (Rechnung)', group: 'Rechnungsadresse' },
{ path: 'billingAddress.postalCodeCity', label: 'PLZ + Stadt (Rechnung)', group: 'Rechnungsadresse' },
{ path: 'billingAddress.country', label: 'Land (Rechnung)', group: 'Rechnungsadresse' },
{ path: 'billingAddress.full', label: 'Vollständige Adresse (Rechnung)', group: 'Rechnungsadresse' },
// Bankverbindung
{ path: 'bankCard.iban', label: 'IBAN', group: 'Bank' },
{ path: 'bankCard.bic', label: 'BIC', group: 'Bank' },
@@ -386,7 +389,12 @@ export async function generateFilledPdf(
const docTypeLabels: Record<string, string> = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' };
const addr = contract.address;
const bAddr = contract.billingAddress;
// Wenn keine separate Rechnungsadresse hinterlegt ist, fällt der Wert auf
// die Lieferadresse zurück konsistent mit der Kundenakte-Logik
// (Contract.billingAddressId NULL = "Wie Lieferadresse"). Damit füllen
// Auftragsformulare die Rechnungs-Felder nicht mehr leer aus, wenn der
// Anbieter eine identische Adresse erwartet.
const bAddr = contract.billingAddress ?? contract.address;
const dataContext: Record<string, string> = {
// Kunde
@@ -437,6 +445,8 @@ export async function generateFilledPdf(
'billingAddress.postalCode': bAddr?.postalCode || '',
'billingAddress.city': bAddr?.city || '',
'billingAddress.postalCodeCity': bAddr ? `${bAddr.postalCode} ${bAddr.city}` : '',
'billingAddress.country': bAddr?.country || '',
'billingAddress.full': bAddr ? `${bAddr.street} ${bAddr.houseNumber}, ${bAddr.postalCode} ${bAddr.city}` : '',
// Bank
'bankCard.iban': bankCard?.iban || '',
'bankCard.bic': bankCard?.bic || '',
+61 -20
View File
@@ -1,5 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
import { stripHtml, isValidEmail, sanitizePhoneField, validateProviderAddress } from '../utils/sanitize.js';
import { validateHttpUrl } from '../utils/url.js';
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
@@ -51,48 +51,89 @@ export async function getProviderById(id: number) {
// Aktuell escapt React das Textnode, also kein direkter XSS aber neue
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
function stripProviderStrings<T extends { name?: string; usernameFieldName?: string; passwordFieldName?: string }>(data: T): T {
//
// 2026-06-21: contactEmail/cancellationEmail laufen zusätzlich durch
// isValidEmail (Header-Injection-Schutz für künftige Mail-Templates),
// contactPhone/contactFax/cancellationFax durch sanitizePhoneField
// (kein CRLF/Control-Char), Postadressen durch sanitizeNotes mit
// 500-Cap (mehrzeilig, normalisierte Newlines).
function stripProviderStrings<T extends object>(data: T): T {
const out: any = { ...data };
if (typeof out.name === 'string') out.name = stripHtml(out.name);
if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName);
if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName);
for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) {
if (typeof out[k] === 'string') out[k] = stripHtml(out[k]);
}
for (const k of ['contactEmail', 'cancellationEmail'] as const) {
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
if (out[k] === undefined) continue;
const stripped = typeof out[k] === 'string' ? stripHtml(out[k]) : out[k];
const value = typeof stripped === 'string' ? stripped.trim() : stripped;
if (value === '' ) { out[k] = null; continue; }
if (!isValidEmail(value)) {
throw new Error(`${k === 'contactEmail' ? 'Kontakt-Emailadresse' : 'Kündigungs-Emailadresse'} ist ungültig.`);
}
out[k] = value;
}
const phoneLabels: Record<string, string> = {
contactPhone: 'Kontakt-Telefonnummer',
contactFax: 'Kontakt-Faxnummer',
cancellationFax: 'Kündigungs-Faxnummer',
};
for (const k of ['contactPhone', 'contactFax', 'cancellationFax'] as const) {
if (out[k] === undefined) continue;
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
const v = sanitizePhoneField(out[k], phoneLabels[k]);
out[k] = v === undefined ? null : v;
}
const addressLabels: Record<string, string> = {
contactAddress: 'Kontakt-Postadresse',
cancellationAddress: 'Kündigungs-Postadresse',
};
for (const k of ['contactAddress', 'cancellationAddress'] as const) {
if (out[k] === undefined) continue;
// R89.1/R89.2: validateProviderAddress wirft 400 bei Längen-
// Verstoß, HTML, Tabs oder Steuerzeichen. Kein silent truncate,
// kein silent null-overwrite mehr.
out[k] = validateProviderAddress(out[k], addressLabels[k]);
}
return out;
}
export async function createProvider(data: {
name: string;
interface ProviderWritable {
name?: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
}) {
contactEmail?: string | null;
contactPhone?: string | null;
contactFax?: string | null;
contactAddress?: string | null;
cancellationEmail?: string | null;
cancellationFax?: string | null;
cancellationAddress?: string | null;
isActive?: boolean;
}
export async function createProvider(data: ProviderWritable & { name: string }) {
const clean = stripProviderStrings(data);
const portalUrl = assertValidPortalUrl(clean.portalUrl);
return prisma.provider.create({
data: {
...clean,
name: clean.name,
portalUrl,
isActive: true,
},
});
}
export async function updateProvider(
id: number,
data: {
name?: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
isActive?: boolean;
}
) {
export async function updateProvider(id: number, data: ProviderWritable) {
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
// damit Prisma nicht den alten Wert hält.
const updateData: typeof data = stripProviderStrings(data);
const updateData: any = stripProviderStrings(data);
if (data.portalUrl !== undefined) {
const validated = assertValidPortalUrl(data.portalUrl);
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
updateData.portalUrl = validated ?? null;
}
return prisma.provider.update({
where: { id },
+301 -1
View File
@@ -11,6 +11,75 @@ import {
getActiveProviderConfig,
} from './emailProvider/emailProviderService.js';
import { generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiError } from '../utils/apiError.js';
// Locker, aber strikt genug gegen offensichtlichen Müll (CRLF, Spaces,
// Komma). Wirklich validiert wird vom Provider beim Sync.
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[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string' && x.trim() !== '');
} catch {
return [];
}
}
export function serializeAdditionalForwards(list: string[]): string | null {
const cleaned = list.map((s) => s.trim()).filter((s) => s !== '');
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 {
if (typeof value !== 'string') {
throw new ApiError(400, 'Ungültige Weiterleitungs-E-Mail-Adresse');
}
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > 254) {
throw new ApiError(400, 'Weiterleitungs-E-Mail-Adresse ist leer oder zu lang');
}
if (!FORWARD_EMAIL_REGEX.test(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();
}
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
const where: Record<string, unknown> = { customerId };
@@ -83,6 +152,27 @@ export interface CreateEmailData {
export async function createEmail(data: CreateEmailData) {
const { provisionAtProvider, createMailbox, ...emailData } = data;
// Duplikatscheck: pro Kunde darf eine Stressfrei-Adresse nur einmal
// existieren. Case-insensitive, weil RFC-localpart-Großschreibung in
// der Praxis nie semantischen Unterschied macht und der Provider eh
// einheitlich lowercased.
const normalized = data.email.trim().toLowerCase();
const conflict = await prisma.stressfreiEmail.findFirst({
where: {
customerId: data.customerId,
email: { equals: normalized },
},
select: { id: true, isActive: true },
});
if (conflict) {
const hint = conflict.isActive
? 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.'
: 'Diese E-Mail-Adresse existiert bei diesem Kunden bereits (deaktiviert). Bitte reaktiviere den vorhandenen Eintrag, statt einen neuen anzulegen.';
throw new ApiError(409, hint);
}
// Wert in DB ist eh schon lowercase wir setzen es einheitlich.
emailData.email = normalized;
// Falls beim Provider anlegen gewünscht
if (provisionAtProvider) {
// Kunde laden für Weiterleitung
@@ -153,6 +243,34 @@ export async function updateEmail(
isActive?: boolean;
}
) {
// Beim Umbenennen der E-Mail-Adresse erneut Kollisionscheck. Sonst
// kann man eine zweite Adresse mit identischer Mail beim selben Kunden
// anlegen (Umweg um den Create-Check).
if (typeof data.email === 'string' && data.email.trim() !== '') {
const normalized = data.email.trim().toLowerCase();
const current = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { customerId: true, email: true },
});
if (!current) {
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
}
if (normalized !== current.email.toLowerCase()) {
const conflict = await prisma.stressfreiEmail.findFirst({
where: {
customerId: current.customerId,
email: { equals: normalized },
NOT: { id },
},
select: { id: true },
});
if (conflict) {
throw new ApiError(409, 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.');
}
}
data.email = normalized;
}
return prisma.stressfreiEmail.update({
where: { id },
data,
@@ -163,6 +281,98 @@ export async function deleteEmail(id: number) {
return prisma.stressfreiEmail.delete({ where: { id } });
}
/**
* Komplette Liste zusätzlicher Weiterleitungs-E-Mails ersetzen und
* direkt mit dem Provider synchronisieren. Aufrufer hat eine canonical
* 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.
*
* 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 + 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 } },
},
});
if (!meta) {
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
}
const previousRaw = meta.additionalForwardingEmails;
const customerEmailKey = meta.customer?.email
? canonicalEmailKey(meta.customer.email)
: null;
const selfKey = canonicalEmailKey(meta.email);
// Input normalisieren + Duplikate raus.
const seen = new Set<string>();
if (customerEmailKey) seen.add(customerEmailKey);
const cleaned: string[] = [];
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);
}
}
const nextRaw = serializeAdditionalForwards(cleaned);
await prisma.stressfreiEmail.update({
where: { id },
data: { additionalForwardingEmails: nextRaw },
});
// Provider unmittelbar nachziehen, sonst läuft das Plesk-Mail-Konto
// mit der alten Liste weiter. autoImport=false, weil unsere DB-Liste
// hier die explizite User-Intent ist kein Plesk-Member-Auto-Pull,
// sonst landen gerade entfernte Adressen zurück in der Liste.
const syncResult = await syncForwardingForEmail(id, { autoImportPleskMembers: false });
// 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)
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
@@ -298,6 +508,7 @@ export async function getDecryptedPassword(id: number): Promise<string | null> {
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
export async function syncForwardingForEmail(
id: number,
options: { autoImportPleskMembers?: boolean } = {},
): Promise<{
success: boolean;
forwardTargets?: string[];
@@ -305,6 +516,14 @@ export async function syncForwardingForEmail(
passwordReset?: boolean;
error?: string;
}> {
// Auto-Import übernimmt unbekannte Plesk-Members in unsere DB. Macht
// beim Sync-Button Sinn (Bestands-Migration), aber NICHT beim
// User-getriggerten Add/Remove dort ist die DB-Liste die Wahrheit.
// Sonst kreisen entfernte Adressen zurück in die Liste:
// 1. User entfernt c → DB=[a,b], Plesk=[a,b,c]
// 2. Auto-Import: "c ist in Plesk aber nicht in DB → in DB schreiben"
// 3. → DB=[a,b,c], Diff sagt nichts zu löschen, Plesk bleibt [a,b,c].
const autoImport = options.autoImportPleskMembers ?? true;
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: {
@@ -313,6 +532,7 @@ export async function syncForwardingForEmail(
isProvisioned: true,
hasMailbox: true,
emailPasswordEncrypted: true,
additionalForwardingEmails: true,
},
});
@@ -333,10 +553,90 @@ export async function syncForwardingForEmail(
if (config?.defaultForwardEmail) {
forwardTargets.push(config.defaultForwardEmail);
}
// Zusätzliche Weiterleitungsziele (vom User im Modal gepflegt). Duplikate
// gegen die Stamm-Mail oder den Default werden hier weggefiltert, damit
// 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)) {
const key = canonicalEmailKey(extra);
if (!seenKeys.has(key)) {
seenKeys.add(key);
forwardTargets.push(extra);
}
}
const localPart = stressfreiEmail.email.split('@')[0];
// 1) Forwards neu setzen.
// 0) Auto-Migration: Plesk hat zwei Verteil-Mechanismen (Mailgroup +
// Forwarding). Alt-Anlagen liefen oft via Mailgroup unser Sync
// schreibt aber nur in die Forwarding-Liste, daher landeten neue
// Adressen nirgendwo. Hier holen wir die aktuellen Mailgroup-Members
// ab und ziehen alle, die wir nicht schon kennen, in unsere
// additionalForwardingEmails-Liste rein. Der nachfolgende Plesk-Call
// deaktiviert dann die Mailgroup und schreibt die volle Liste als
// Forwarding. Verlustfrei kein Empfänger fällt raus.
// Pentest 83.2: Self-Forward auch beim Import blocken. Die
// Stressfrei-Adresse selbst darf nicht aus Plesk in unsere DB
// landen sonst läuft sie nach dem Mailgroup→Forwarding-Umschalten
// als Forwarding-Target auf sich selbst (Mail-Loop).
seenKeys.add(canonicalEmailKey(stressfreiEmail.email));
if (autoImport) {
try {
const pleskState = await checkEmailExists(localPart);
const existingMembers = [
...(pleskState.mailgroupMembers ?? []),
...(pleskState.forwardingTargets ?? []),
];
const newImports: string[] = [];
for (const member of existingMembers) {
// Pentest 83.1: importierte Adressen aus Plesk müssen denselben
// Filter passieren wie User-Eingaben (TLD-Blocklist, Format).
// Sonst rutschen reservierte TLDs wie `.internal` ohne Check
// in unsere DB, falls ein Plesk-Admin sie dort manuell gepflegt
// hat. Ungültige werden silent gedroppt Log informiert.
let validated: string;
try {
validated = assertValidForwardingEmail(member);
} catch (validationErr) {
const reason = validationErr instanceof Error ? validationErr.message : 'unbekannt';
console.debug(
`[syncForwardingForEmail] Plesk-Member "${member}" verworfen: ${reason}`,
);
continue;
}
const key = canonicalEmailKey(validated);
if (!seenKeys.has(key)) {
seenKeys.add(key);
forwardTargets.push(validated);
newImports.push(validated);
}
}
if (newImports.length > 0) {
const mergedAdditional = [
...parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails),
...newImports,
];
await prisma.stressfreiEmail.update({
where: { id },
data: { additionalForwardingEmails: serializeAdditionalForwards(mergedAdditional) },
});
// Pentest 83.3: PII-Logs auf debug-Level statt log-Level.
console.debug(
`[syncForwardingForEmail] Importiert aus Plesk-Mailgroup für ${stressfreiEmail.email}:`,
newImports,
);
}
} catch (importErr) {
// Nicht hart fehlschlagen im schlimmsten Fall fehlen ein paar
// alte Empfänger, aber der eigentliche Sync soll trotzdem laufen.
console.error('[syncForwardingForEmail] Mailgroup-Import fehlgeschlagen:', importErr);
}
}
// 1) Forwards neu setzen (deaktiviert intern Mailgroup).
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
if (!forwardResult.success) {
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
+18
View File
@@ -0,0 +1,18 @@
/**
* Erlaubt Service-/Helper-Funktionen, einen Fehler mit explizitem HTTP-
* Status nach oben zu reichen. Controller können in ihrem `catch` per
* `instanceof ApiError` den Status auslesen statt pauschal 500 zu liefern.
*
* Pentest 64.1 (LOW, 2026-06-02): Race-Lock (assertNoRecentDuplicate-
* Document) warf einen generischen Error → catch hat 500 zurückgegeben,
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
* angelegt" eindeutig eine 400-Class-Situation ist.
*/
export class ApiError extends Error {
readonly statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.name = 'ApiError';
this.statusCode = statusCode;
}
}
+344
View File
@@ -4,6 +4,8 @@
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
*/
import { ApiError } from './apiError.js';
// Felder die NIE in einer API-Response an den Client gehen dürfen
const SENSITIVE_CUSTOMER_FIELDS = [
'portalPasswordHash',
@@ -15,6 +17,10 @@ const SENSITIVE_CUSTOMER_FIELDS = [
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
'consentHash',
// Pentest 57.7 (2026-06-01): TTL des Public-Consent-Links kein Leak
// an die Standard-Customer-Response, da Existenz/Ablaufzeit Info über
// den Workflow gibt.
'consentHashExpiresAt',
// Session-/OTP-State Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
@@ -69,6 +75,9 @@ const CONTRACT_DISPLAY_STRING_FIELDS = [
'tariffName',
'customerNumberAtProvider',
'contractNumberAtProvider',
'orderNumberAtSalesPlatform',
'customerNumberAtSalesPlatform',
'contractNumberAtSalesPlatform',
'portalUsername',
'previousProviderName',
'previousCustomerNumber',
@@ -166,6 +175,341 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
* Audit-Log abrufbar) und sanitisiert das embedded customer.
*/
// Sanitisierung für freitextliche User-Notizen (ContractDocument.notes,
// Invoice.notes, MeterReading.notes etc.). Pentest 55.2 (MEDIUM,
// 2026-06-01): 50 000-Zeichen-Inputs mit XSS-Payload und CRLF gingen
// roh in die DB. Selbst wenn React escapt, sind sie ein Header-Injection-
// und Speicher-Risiko, wenn die Notiz mal in Mail/PDF/CSV-Export fließt.
// - Tags + gefährliche Schemata via stripHtml
// - CRLF auf Newline normalisieren, keine Carriage-Returns persistieren
// - Length-Cap default 2000 Zeichen (genug für sinnvolle Anmerkungen)
// Pentest 58.1 (MEDIUM, 2026-06-01): documentType wurde nur durch
// stripHtml geschickt, aber NICHT gegen eine Whitelist geprüft. Damit
// landeten beliebige Strings (`NICHT_ERLAUBT`, `DROP TABLE …`,
// Tippfehler-Werte aus alten UI-Versionen) als documentType in der
// ContractDocument-Tabelle und brachen Frontend-Filter, Auto-Activation
// (Lieferbestätigung-Trigger) und Reports.
//
// Whitelist spiegelt die Konstante CONTRACT_DOCUMENT_TYPES aus
// SaveAttachmentModal / SaveEmailAsPdfModal im Frontend. Beide
// Listen MÜSSEN synchron gehalten werden idealerweise später
// in eine geteilte Konfiguration gehoben.
export const ALLOWED_CONTRACT_DOCUMENT_TYPES = [
'Auftragsformular',
'Auftragsbestätigung',
'Lieferbestätigung',
'Vertragsunterlagen',
'Vollmacht',
'Widerrufsbelehrung',
'Preisblatt',
'Sonstiges',
] as const;
const CONTRACT_DOCUMENT_TYPE_SET: Set<string> = new Set(ALLOWED_CONTRACT_DOCUMENT_TYPES);
/**
* Validiert + normalisiert einen documentType-Wert. Wirft einen Fehler
* mit klarer Liste, wenn der Wert nicht in der Whitelist steht (der
* aufrufende Controller mappt das auf 400). Trimmt Whitespace und macht
* den Vergleich case-insensitive damit `"lieferbestätigung"` aus
* Drittsystemen sauber matched, aber `"Lieferbestätigung_DROP"` rausfliegt.
*/
export function validateContractDocumentType(raw: unknown): string {
if (typeof raw !== 'string') {
throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
}
const cleaned = stripHtml(raw) as string;
const trimmed = cleaned.trim();
if (trimmed === '') {
throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
}
const canonical = ALLOWED_CONTRACT_DOCUMENT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase());
if (!canonical) {
throw new Error(`Ungültiger documentType '${trimmed}'. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
}
return canonical;
}
// Pentest 26.7 LOW (defense-in-depth, 2026-06-02): documentPath wird
// (außer beim Upload-Endpoint) NIE direkt aus User-Input übernommen.
// Falls doch jemand auf die Idee kommt, das Feld irgendwo zu mappen,
// fangen wir hier Path-Traversal / Javascript-URIs / HTML ab.
// Spiegelt isValidDocumentPath aus prisma/cleanup-xss-and-mass-assignment.ts
// 1:1 Single Source of Truth für Lese- UND Schreibpfad.
export function isValidDocumentPath(v: string | null | undefined): boolean {
if (!v) return true; // null/leer ist OK Feld bleibt einfach unbesetzt
if (typeof v !== 'string') return false;
if (v.includes('..')) return false;
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
if (/<[a-z!/]/i.test(v)) return false;
return /^\/?uploads\/[A-Za-z0-9._\-/]+$/.test(v);
}
export function assertValidDocumentPath(v: string | null | undefined, fieldLabel = 'documentPath'): void {
if (!isValidDocumentPath(v)) {
throw new Error(`${fieldLabel} ist kein gültiger Upload-Pfad (erlaubt: /uploads/<safe>).`);
}
}
// Pentest 68.1 (LOW, 2026-06-03): PDFs mit JavaScript, /Launch (externes
// Programm), /EmbeddedFile (eingebettete Executables) oder /RichMedia
// (Flash) kamen durch den reinen Magic-Byte-Check (%PDF-) und wurden
// inline ausgeliefert. Browser-PDF-Viewer (PDFium/PDF.js) führen kein JS
// aus, aber sobald jemand die PDF in Adobe Acrobat öffnet, läuft sie.
// → Wir blocken das schon beim Upload.
//
// PDF-Name-Objekte sind laut PDF 32000-1:2008 §7.3.5 case-sensitive, also
// kein /i auf den Patterns. Whitespace nach `/` ist im Standard zwar
// erlaubt, in real-world Exploits aber praktisch nie zu sehen wir
// bleiben hier pragmatisch.
//
// Hinweis: erkannt wird nur, was im Klartext im PDF-Body steht.
// Komprimierte oder verschlüsselte Streams entgehen dem String-Scan.
// Für unser Bedrohungsmodell (kompromittierter Staff-Account, LOW) reicht
// das ein vollständiger PDF-Parser wäre Overkill.
const PDF_DANGER_PATTERNS: { pattern: RegExp; label: string }[] = [
{ pattern: /\/JavaScript\b/, label: 'JavaScript-Action' },
{ pattern: /\/JS\b/, label: 'JavaScript-Action' },
{ pattern: /\/Launch\b/, label: 'Launch-Action (externes Programm)' },
{ pattern: /\/EmbeddedFile\b/, label: 'eingebettete Datei' },
{ pattern: /\/RichMedia\b/, label: 'RichMedia-Inhalt (Flash)' },
];
export function assertSafePdf(buf: Buffer): void {
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
return; // keine PDF → andere Validatoren zuständig
}
// Stream-Inhalte (Bilder/Fonts/Komprimiertes) aus dem Scan rausnehmen.
// Jpeg-Bytes können zufällig "/JavaScript" enthalten → false-positive
// bei jsPDF-generierten PDFs mit eingebetteten Fotos (stage-Bug
// 2026-06-03). Echte aktive PDF-Inhalte stehen IMMER im PDF-
// Object-Stream (außerhalb von `stream..endstream`-Blöcken).
const scanTarget = buf.toString('latin1').replace(/stream\s[\s\S]*?endstream/g, '');
for (const { pattern, label } of PDF_DANGER_PATTERNS) {
if (pattern.test(scanTarget)) {
throw new ApiError(
415,
`PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`,
);
}
}
}
// Pentest 51.3 + 60.3 (MEDIUM, 2026-06-01): Telefon-/Vorwahl-Felder
// dürfen NIE CRLF oder andere Control-Chars enthalten sonst sind sie
// ein Header-Injection-Vektor (Mail, HTTP), wenn der Wert mal in einen
// Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf
// literales Space beschränkt, NICHT `\s` das matched sonst `\r\n\t`.
// Allowed: Ziffern, Plus, Minus, Slash, Klammern, Punkt, Space. Bis 40 Zeichen.
//
// 51.3 deckte nur Contract-Phone-Felder ab; 60.3: `Customer.phone` /
// `Customer.mobile` waren immer noch offen, weil pickCustomerUpdate nur
// stripHtml laufen ließ das filtert keine Control-Chars.
const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/;
export function sanitizePhoneField(raw: unknown, fieldLabel: string): string | undefined {
if (raw == null) return undefined;
const trimmed = String(raw).trim();
if (trimmed === '') return undefined;
if (!PHONE_FIELD_ALLOWED.test(trimmed)) {
throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`);
}
return trimmed;
}
// Pentest 62.7 (LOW, 2026-06-02): deliveryDate/confirmationDate-Felder
// liefen ungeprüft in maybeActivateOnDeliveryConfirmation. XSS-Payloads
// gingen mit 200 durch, weil das ungültige Datum nur silent als null
// behandelt wurde. Impact gering, aber API-Hygiene: ungültige Eingabe
// soll 400 zurückgeben, nicht 200.
//
// Akzeptiert: ISO-8601-Datum (YYYY-MM-DD) und Datum+Zeit (mit oder ohne
// Zeitzone). Whitespace wird getrimmt. null / leerer String / undefined
// sind OK der Aufrufer behandelt das als "Datum nicht gesetzt".
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') {
throw new Error(`${fieldLabel} muss ein Datums-String (YYYY-MM-DD) sein.`);
}
const trimmed = raw.trim();
if (trimmed === '') return null;
if (!ISO_DATE_REGEX.test(trimmed)) {
throw new Error(`${fieldLabel} muss ISO-8601-Format haben (YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS).`);
}
const parsed = new Date(trimmed);
if (isNaN(parsed.getTime())) {
throw new Error(`${fieldLabel} ist kein gültiges Datum.`);
}
return trimmed;
}
// Pentest 86.1 + 86.2 (LOW, 2026-06-19): Kunden-/Vertrags-/Auftrags-
// Nummern bei Anbieter und Vertriebsplattform hatten keine Längen- oder
// Zeichen-Validierung. >1000 Zeichen-Strings warfen einen generischen
// 500er (DB-Overflow VARCHAR(191)) statt eines 400ers. Außerdem
// überlebten Attribut-Injection-Payloads wie `foo" onerror="alert(1)`
// die stripHtml-Defense (kein umschließender Tag → kein Match), die
// in PDF-/Mail-Export potentiell aktiv werden könnten.
//
// Whitelist orientiert sich am Vorschlag des Pentesters
// `^[\w\-\/\s\.]*$` Whitespace ist hier bewusst NUR ein literales
// Space, NICHT `\s` (kein CRLF/Tab → kein Header-Injection-Vektor
// in CSV-/Mail-Exporten). Max 100 Zeichen reicht für jede reale
// Kunden-/Vertrags-Nummer und bleibt deutlich unter dem VARCHAR(191)-
// Limit der DB-Spalte.
const CONTRACT_IDENTIFIER_FIELDS: ReadonlySet<string> = new Set([
'customerNumberAtProvider',
'contractNumberAtProvider',
'orderNumberAtSalesPlatform',
'customerNumberAtSalesPlatform',
'contractNumberAtSalesPlatform',
]);
const CONTRACT_IDENTIFIER_ALLOWED = /^[A-Za-z0-9_\-/. ]{0,100}$/;
const CONTRACT_IDENTIFIER_MAX_LEN = 100;
export function isContractIdentifierField(key: string): boolean {
return CONTRACT_IDENTIFIER_FIELDS.has(key);
}
export function validateContractIdentifier(
raw: unknown,
fieldLabel: string,
): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') {
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
}
const trimmed = raw.trim();
if (trimmed === '') return null;
if (trimmed.length > CONTRACT_IDENTIFIER_MAX_LEN) {
throw new ApiError(
400,
`${fieldLabel} darf maximal ${CONTRACT_IDENTIFIER_MAX_LEN} Zeichen lang sein.`,
);
}
if (!CONTRACT_IDENTIFIER_ALLOWED.test(trimmed)) {
throw new ApiError(
400,
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, Leerzeichen).`,
);
}
return trimmed;
}
// Pentest 95.1/95.3/95.4 (MEDIUM/LOW, 2026-06-21): Manuelles
// `portalUsername` am Vertrag hatte gar keine Validierung. Drei
// nachweisbare Effekte:
// - `foo\r\nBcc:evil@x.de` (CRLF) verbatim gespeichert →
// Header-Injection-Vektor sobald der Wert in Mail-Templates
// oder PDF-Footers landet.
// - `<script>alert(1)</script>@x.de` lief durch stripHtml →
// stille Mutation (R87.1/R89.2-Pattern auf neuem Feld).
// - >190 Zeichen → VARCHAR-Overflow → generischer 500 statt 400.
//
// Bewusst NICHT übernommen wurde R95.2 (Email-Format-Pflicht):
// `portalUsername` ist im Manual-Modus nicht zwingend eine
// E-Mail. Vodafone, 1&1, EWE und etliche Stadtwerke nutzen
// Kundennummern, Pseudonyme oder Customer-IDs als Portal-Login.
// Eine `email().regex()`-Pflicht würde legitime Logins ablehnen.
// Der Stressfrei-Modus hängt eh an einer schon validierten
// Email-Stammdate (assertValidForwardingEmail).
//
// Allowed: Alphanumerisch + `_`, `-`, `.`, `/`, `@`, `+`, Space.
// Damit sind Vodafone-Kunden-IDs (`12345678`), Pseudonyme
// (`max.mustermann`), Plus-Tag-Emails (`m+tag@example.com`)
// und gemischte Formen abgedeckt. Strukturell sind CRLF, Tab,
// alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne
// extra Check. Max 100 Zeichen << VARCHAR(191) → R95.4.
const PORTAL_USERNAME_ALLOWED = /^[A-Za-z0-9_\-/.@+ ]{0,100}$/;
const PORTAL_USERNAME_MAX_LEN = 100;
export function validatePortalUsername(
raw: unknown,
fieldLabel = 'portalUsername',
): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') {
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
}
const trimmed = raw.trim();
if (trimmed === '') return null;
if (trimmed.length > PORTAL_USERNAME_MAX_LEN) {
throw new ApiError(
400,
`${fieldLabel} darf maximal ${PORTAL_USERNAME_MAX_LEN} Zeichen lang sein.`,
);
}
if (!PORTAL_USERNAME_ALLOWED.test(trimmed)) {
throw new ApiError(
400,
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, @, +, Leerzeichen).`,
);
}
return trimmed;
}
// Pentest 89.1 + 89.2 (MEDIUM/LOW, 2026-06-21): Postadressen am
// Provider (`contactAddress`, `cancellationAddress`). sanitizeNotes
// hat das Length-Cap silent durchgeschoben (slice statt Error) und
// stripHtml lief vor dem Length-Check derselbe Fehler wie R87:
// `<script>…</script>` reduziert auf leeren String → null in der
// DB → vorheriger Wert ohne Fehlermeldung überschrieben.
//
// Lösung wie R87: Raw-Input validieren, harte 400 statt silent-Mutation.
// - max 500 Zeichen (mehrzeilige Postadresse ≈ 4 Zeilen × 80)
// - `<` oder `>` direkt 400 (Postadressen brauchen kein HTML)
// - Steuerzeichen außer `\n` direkt 400 (kein CR/Tab/Null/etc)
// - leerer/getrimmt-leerer Input → null (Feld zurücksetzen)
// - CRLF → LF normalisieren
const PROVIDER_ADDRESS_MAX_LEN = 500;
// Erlaubt: nur LF (`\x0A`) als Newline. Alles andere inkl. Tab (`\x09`)
// fliegt raus. Tab in Postadressen ist Header-Injection-Vektor für CSV/Mail
// und nichts, was ein Mensch je tippt.
const PROVIDER_ADDRESS_BAD_CHARS = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]/;
export function validateProviderAddress(
raw: unknown,
fieldLabel: string,
): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') {
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
}
// CRLF → LF NORMALISIEREN bevor wir auf Länge prüfen ein Editor der
// immer `\r\n` schickt würde sonst bei jedem Zeilenumbruch zwei
// Zeichen gegen das 500er-Cap zählen.
const normalized = raw.replace(/\r\n?/g, '\n');
if (PROVIDER_ADDRESS_BAD_CHARS.test(normalized)) {
throw new ApiError(
400,
`${fieldLabel} enthält unzulässige Zeichen (HTML, Tabs oder Steuerzeichen sind in Postadressen nicht erlaubt).`,
);
}
if (normalized.length > PROVIDER_ADDRESS_MAX_LEN) {
throw new ApiError(
400,
`${fieldLabel} darf maximal ${PROVIDER_ADDRESS_MAX_LEN} Zeichen lang sein.`,
);
}
const trimmed = normalized.trim();
return trimmed === '' ? null : trimmed;
}
const NOTES_DEFAULT_MAX = 2000;
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') return null;
const stripped = stripHtml(raw) as string;
// CR allein → entfernen (CRLF → LF); restliche Steuerzeichen außer \n
// herausfiltern. Null/Form-Feed/Tabs raus.
const normalized = stripped
.replace(/\r\n?/g, '\n')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
const trimmed = normalized.trim();
if (trimmed === '') return null;
return trimmed.slice(0, maxLength);
}
// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
+60
View File
@@ -84,6 +84,57 @@ const BLOCKED_HOSTNAMES = new Set([
'[::1]',
]);
/**
* Pentest 59.4 (HIGH, 2026-06-01): Node's URL-Parser normalisiert
* IPv4-mapped IPv6 zur **Hex-Form**:
* `::ffff:127.0.0.1` → `::ffff:7f00:1`
* `::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS Metadata!)
* `::ffff:10.0.0.1` → `::ffff:a00:1`
*
* Die alten Patterns (`::ffff:127\.`, `::ffff:10\.` etc.) griffen nur
* auf die Dotted-Form Angreifer konnten via URL-Brackets die Hex-Form
* an der Blocklist vorbeischleusen, weil `new URL()` umnormalisiert.
*
* Lösung: aus IPv4-mapped IPv6 extrahieren wir den IPv4-Anteil und
* lassen ihn durch die IPv4-Patterns laufen. Das deckt beide Schreib-
* weisen + ausgeschriebene Long-Form ab.
*/
function extractMappedIPv4(addr: string): string | null {
// Compact dotted: ::ffff:127.0.0.1
let m = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr);
if (m) return m[1];
// Compact hex: ::ffff:7f00:1
m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr);
if (m) {
const h1 = parseInt(m[1], 16);
const h2 = parseInt(m[2], 16);
if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null;
return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`;
}
// Expanded dotted: 0:0:0:0:0:ffff:127.0.0.1
m = /^0:0:0:0:0:ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr);
if (m) return m[1];
// Expanded hex: 0:0:0:0:0:ffff:7f00:1
m = /^0:0:0:0:0:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr);
if (m) {
const h1 = parseInt(m[1], 16);
const h2 = parseInt(m[2], 16);
if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null;
return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`;
}
return null;
}
function checkIPv4(ipv4: string, includePrivate: boolean): boolean {
if (BLOCKED_HOSTNAMES.has(ipv4)) return true;
for (const pattern of BLOCKED_PATTERNS) if (pattern.test(ipv4)) return true;
if (includePrivate) {
if (PRIVATE_HOSTNAMES.has(ipv4)) return true;
for (const pattern of PRIVATE_IP_PATTERNS) if (pattern.test(ipv4)) return true;
}
return false;
}
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
if (!host) return false;
const h = host.trim().toLowerCase();
@@ -92,6 +143,10 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(h)) return true;
}
// 59.4: IPv4-mapped IPv6 entpacken und die IPv4 separat prüfen
// egal ob Hex- oder Dotted-Form, egal ob compact oder expanded.
const mappedV4 = extractMappedIPv4(h);
if (mappedV4 && checkIPv4(mappedV4, BLOCK_PRIVATE_IPS)) return true;
if (BLOCK_PRIVATE_IPS) {
if (PRIVATE_HOSTNAMES.has(h)) return true;
for (const pattern of PRIVATE_IP_PATTERNS) {
@@ -123,6 +178,11 @@ export function isPrivateOrBlockedHost(host: string | null | undefined): boolean
for (const pattern of PRIVATE_IP_PATTERNS) {
if (pattern.test(h)) return true;
}
// 59.4: IPv4-mapped IPv6 strikt prüfen (Hex- + Dotted-Form,
// compact + expanded). Pentester konnte ::ffff:7f00:1 statt
// ::ffff:127.0.0.1 nutzen, weil URL-Parser umnormalisiert.
const mappedV4 = extractMappedIPv4(h);
if (mappedV4 && checkIPv4(mappedV4, true)) return true;
return false;
}
+181
View File
@@ -473,6 +473,187 @@ Vor jedem Launch mit echten Tokens probieren.
---
## 📝 Bewusste Akzeptanz URL-encoded Route-Parameter
**Finding aus Pentest-Runde 85 (INFO, kein Security-Impact):**
`/api/stressfrei-emails/%31/...` (URL-encoded `1`) liefert dieselbe
Antwort wie `/api/stressfrei-emails/1/...` die `requireIdParam`-
Validierung mit `/^\d+$/` sieht die schon dekodierte Form, weil
Express URL-Parameter **vor** dem Routing dekodiert.
**Wir akzeptieren das als by-design:**
1. RFC 3986 fordert genau dieses Verhalten: prozentual-codierte und
roh-Form derselben Zeichen müssen semantisch identisch behandelt
werden. `/1` und `/%31` ist HTTP-konform äquivalent.
2. Ein nachträglicher Strict-Check auf `req.url` (roh) würde
legitime Clients brechen Browser-Bookmarks mit URL-Encoding,
curl-Calls mit `--data-urlencode`, Proxy-Tools die URL-encoden,
Mobile-Clients mit kanonischer URL-Normalisierung.
3. Der Pentester selbst bestätigt: **kein Security-Impact** die ID
landet nach Dekodierung als gültige Zahl bei der gleichen
Auth-/IDOR-Logik, die auch für die rohe Form greift. Insbesondere
`canAccessStressfreiEmail` läuft identisch.
4. Validierungs- und Access-Control-Kette ist nach der Dekodierung
identisch dicht Auth, Ownership, Rate-Limit, alles greift gleich.
**Code-Notiz:** kein Patch nötig. Diese Markdown-Sektion ist die
einzige Dokumentation, damit das in zukünftigen Pentest-Runden nicht
erneut als „offenes Finding" auftaucht.
---
## 🔒 Runde 86 Vertrags-Identifier-Validierung
**Findings (beide LOW):**
- **R86.1**: Strings >999 Zeichen in `orderNumberAtSalesPlatform` / den
vier verwandten Sales-/Provider-Nummern-Feldern endeten mit
generischem 500 (DB-Overflow `VARCHAR(191)`) statt sauberem 400.
- **R86.2**: Attribut-Injection-Payload `foo" onerror="alert(1)`
(kein umschließender Tag) überlebte `stripHtml`. React escaped
Attribute, aber sobald der Wert in PDF-/Mail-/CSV-Export fließt,
ist es potentiell aktiv.
**Fix:** `validateContractIdentifier(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
- Max-Länge 100 Zeichen (deutlich unter VARCHAR(191)).
- Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$` Buchstaben, Ziffern,
Punkt, Bindestrich, Schrägstrich, Unterstrich und literales
Leerzeichen. Bewusst NICHT `\s` (kein CRLF/Tab → kein
Header-Injection-Vektor in CSV-/Mail-Exporten).
- Bei Verstoß: `ApiError(400, …)` mit konkreter Fehlermeldung
statt 500.
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
fünf Identifier-Felder (`customerNumberAtProvider`,
`contractNumberAtProvider`, `orderNumberAtSalesPlatform`,
`customerNumberAtSalesPlatform`, `contractNumberAtSalesPlatform`)
bei jedem Create/Update.
- Frontend: `maxLength={100}` als zusätzliche UX-Schicht im
ContractForm Server-seitige Validierung bleibt die einzige
Wahrheit, das HTML-Attribut spart nur den unnötigen Round-Trip.
---
## 🔒 Runde 87 Whitelist vor Sanitizer (silent-mutation-Schutz)
**Finding (LOW): Sanitizer-Order maskiert Tag-Verstöße**
Im ursprünglichen R86-Fix lief `stripHtml(body)` **vor**
`validateContractIdentifier`. Das hatte einen subtilen Bypass:
| Payload | Status | Tatsächlich gespeichert |
|--------------------------------------|------------|-------------------------|
| `<b>bold</b>` | 200 OK | `"bold"` (silent strip) |
| `EVN<b>2024</b>` | 200 OK | `"EVN2024"` |
| `<script>alert(1)</script>` | **200 OK** | `null` **vorherigen Wert überschrieben** |
| `foo<bar>baz` | 200 OK | `"foobarbaz"` |
Kein direkter XSS-Vektor (React + DB-Whitelist greifen weiterhin),
aber zwei reale UX-/Datenintegritäts-Risiken:
1. Admin tippt `VG<2024>001`, bekommt 200 zurück, gespeichert ist
`VG2024001` ohne Hinweis auf die Mutation.
2. Werte die komplett aus Tags bestehen (`<script>…</script>`)
werden vom Sanitizer auf den leeren String reduziert →
`null` in der DB → **vorheriger Wert wird stillschweigend
gelöscht**.
**Fix:** Validierungs-Reihenfolge für die fünf Identifier-Felder
umgedreht `validateContractIdentifier` läuft jetzt **direkt
gegen den Raw-Input**, ohne dass `stripHtml` ihn vorher
glättet. Die strikte Whitelist
`^[A-Za-z0-9_\-/. ]{0,100}$` lehnt sowieso alles ab, was
`stripHtml` normalerweise abgefangen hätte (Tags, Schemes,
Zero-Width-Chars, Homoglyphe, Percent-Encoding) Defense-in-
Depth bleibt unverändert, nur jetzt ehrlich (400 statt silent-200).
Single-Line-Patch in [`backend/src/controllers/contract.controller.ts`](../backend/src/controllers/contract.controller.ts)
`sanitizeContractBody`.
---
## 🔒 Runde 89 Provider-Adressfelder härten
**Findings (R89.1 MEDIUM + R89.2 LOW):**
Beim neuen Provider-Modal (`Kontakt + Kündigung`) wurden
`contactAddress` und `cancellationAddress` über `sanitizeNotes(…, 500)`
geleitet. Zwei Probleme:
- **R89.1**: `sanitizeNotes` macht `slice(0, 500)` statt 400 501+ Zeichen
wurden silent auf 500 abgeschnitten und mit 200 OK gespeichert.
- **R89.2**: stripHtml lief vor dem Length-Check derselbe Bug wie R87.
`<script>…</script>` → leerer String → `null` in der DB → vorheriger
Wert ohne Fehlermeldung überschrieben.
**Fix:** Eigener `validateProviderAddress(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
- Validiert den Raw-Input direkt kein stripHtml davor.
- Max 500 Zeichen → `ApiError(400, …)` mit klarer Meldung.
- Zeichen-Blacklist `[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]` erlaubt ist
nur LF (`\n`). HTML-Klammern (`<`, `>`), Tab, NUL, CR-allein, alle
anderen Control-Chars → 400. Tab raus weil Header-Injection-Vektor
für CSV-/Mail-Exporte und in einer Postadresse nie legitim.
- CRLF → LF normalisiert **vor** dem Length-Check, damit ein Editor
mit `\r\n`-Zeilenenden nicht jedes Newline doppelt zählt.
- Leerer / nur-Whitespace Input → `null` (Feld zurücksetzen).
Eingehängt in `stripProviderStrings` für die zwei Adressfelder. Die
übrigen fünf Kontakt-Felder (Email/Telefon/Fax) gehen weiter durch
`isValidEmail` / `sanitizePhoneField` die hat der Pentester explizit
als sauber bestätigt (7/7 + 6/6 Angriffsvektoren geblockt).
**Bewusst nicht gefixt:** R89.3 (Anführungszeichen) und R89.4 (`\n`).
Der Pentester selbst sagt "kein unmittelbares Risiko, React escaped
korrekt". Quotes in `Anbieter "GmbH"` sind legitim, `\n` ist Teil
einer mehrzeiligen Postadresse.
---
## 🔒 Runde 95 Portal-Username-Validierung
**Findings (R95.1 MEDIUM + R95.3 LOW + R95.4 LOW):**
`portalUsername` (Manual-Input-Modus am Vertrag) hatte gar keine
Validierung. Drei nachweisbare Effekte:
- **R95.1**: `foo\r\nBcc:evil@x.de` (CRLF) wurde verbatim
gespeichert → Header-Injection-Vektor sobald der Wert in
Mail-Templates oder PDF-Footers landet.
- **R95.3**: `<script>alert(1)</script>@x.de` lief durch
`stripHtml` → stille Mutation zu `@x.de` (R87.1/R89.2-Pattern
auf neuem Feld).
- **R95.4**: >190 Zeichen → VARCHAR-Overflow → generischer 500
statt sauberem 400.
**Fix:** `validatePortalUsername(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
- Whitelist `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind
CRLF, Tab, alle Control-Chars, Tags (`<`, `>`) und Quotes raus
→ R95.1 und R95.3 ohne extra Check.
- Max 100 Zeichen → `ApiError(400, …)` → R95.4 mit klarer Meldung.
- Raw-Input direkt validiert (kein `stripHtml` davor) gleicher
R87-Pattern wie bei Contract-Identifier und Provider-Address.
- Eingehängt in `sanitizeContractBody` als eigener Branch.
**Bewusst NICHT übernommen: R95.2 (Email-Format-Pflicht).**
Der Pentester schlägt `z.string().email()` vor, weil der „Kunde
sich sonst nicht einloggen kann". Falsche Annahme: `portalUsername`
ist im Manual-Modus **nicht zwingend eine E-Mail**. Vodafone, 1&1,
EWE und etliche Stadtwerke nutzen reine Kundennummern (`12345678`),
Pseudonyme (`max.mustermann`) oder Customer-IDs als Portal-Login.
Eine Email-Pflicht würde legitime Logins ablehnen. Der Stressfrei-
Modus hängt sowieso an einer schon validierten Email-Stammdate
(`assertValidForwardingEmail`).
---
## 🧭 Wann ist „dicht" dicht?
100 % gibt es nicht. Erreicht ist:
+586
View File
@@ -97,6 +97,592 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔧 Pentest R101.1 Inline-Preview-Pfad refaktoriert + Diagnose-Log**
- Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
`fileDownload.controller` ist eigentlich korrekt sauberer Magic-
Byte-Check für PDF/PNG/JPEG/GIF/WebP und liefert beim Direkttest
gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
aber nicht reproduzieren.
- Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
extrahiert, finally-Block schließt File-Descriptor garantiert,
Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
- Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
R30.13).
- Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
der Fall im Prod-Log auf, falls er nochmal auftritt bisher
war's silent.
- [x] **🔒 Pentest R97 Attachment-Validierung im Send-Handler**
- R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
durchgehen.
- R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) die im
Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
- Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
läuft **vor** dem `sendEmail`-Aufruf:
- `attachments` muss Array oder undefined sein
- max 25 Anhänge
- jeder: `filename` non-empty String, `content` non-empty Base64-
String (Regex), optional `contentType` String
- max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
kein Buffer.from-Aufruf während der Validierung)
- Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
grün inkl. aller R97.1-Pentest-Payloads.
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
"Datei anhängen":
- **Vertragsdokumente**: listet alle am Vertrag gespeicherten Dokumente
gruppiert nach `documentType`. Auswahl → Server-Download via
`fileUrl` (Token-Auth, Per-File-Ownership-Check greift) → base64 →
direkt in die Anhang-Liste. Respektiert das 25-MB-Gesamtlimit.
- **Kundendaten einfügen**: zeigt nur Sections die tatsächlich Daten
haben (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag,
Bank, Ausweis). Pro Section Checkbox + Preview. Bei Bank +
Ausweis zusätzlich Sub-Checkbox "als PDF anhängen", wenn ein
`documentPath` vorhanden ist.
- Beim Bestätigen werden die Text-Blöcke an das Body-Ende gehängt
(mit `\n\n`-Separator), Anhänge per `serverFileToAttachment` aus
`composeAttachmentHelpers.ts` gezogen. Anhang-Limit (25 MB gesamt)
wird beidseitig geprüft, drüberlaufende Dateien werden mit Toast
übersprungen statt silent weggeschluckt.
- Helpers (`composeAttachmentHelpers.ts`):
- `serverFileToAttachment(path, filename)` fetch via Token-URL
→ Blob → base64 → `EmailAttachment`.
- `totalAttachmentBytes` Größen-Check unter Berücksichtigung der
~33 % base64-Overhead.
- `bankCardAttachmentName` / `identityDocAttachmentName`
sprechende Dateinamen für den Empfänger.
- [x] **🔒 Pentest R95 Portal-Username (Manual-Modus) härten**
- R95.1 (MEDIUM): `foo\r\nBcc:evil@x.de` → Header-Injection-Vektor
sobald der Wert in Mail-Templates / PDF-Footer landet.
- R95.3 (LOW): `<script>…</script>@x.de` → silent stripHtml-Mutation
(R87.1-Pattern, dritter Treffer auf demselben Bug).
- R95.4 (LOW): >190 Zeichen → VARCHAR-Overflow → 500 statt 400.
- Fix: `validatePortalUsername()` in `sanitize.ts` mit Whitelist
`^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind CRLF, Tab, alle
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
Check. Max 100 → `ApiError(400)` → R95.4 sauber. Raw-Input direkt
validiert (R87-Pattern). Eingehängt in `sanitizeContractBody`.
- Frontend: `maxLength={100}` am Input.
- **R95.2 bewusst nicht übernommen** (Email-Format-Pflicht): das
Feld ist im Manual-Modus nicht zwingend eine E-Mail Vodafone,
1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als
Portal-Login. Doku in `SECURITY-HARDENING.md § Runde 95`.
- [x] **🔒 Pentest R93 Leerer String != fehlender Param**
- R93.1 (INFO): `?accountId=` (explizit-leer) wurde wie `?accountId`
weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.
Pentester-Spec: leerer String ist KEINE gültige Zahl.
- Fix im `parsePositiveIntQuery()`-Helper: striktere Absent-Logik
(`v === undefined` ist absent; `''`, `' '`, alles andere muss
parsen). Required + optional Modes unverändert.
- Float-Grenzfall (`accountId=5.5` → 5 via `parseInt`) bleibt als
by-design akzeptiert (Pentester-Bestätigung, kein Security-Impact).
- [x] **🔒 Pentest R92 Strict-400 für accountId auf Vertrags-Endpunkten**
- R91-Fix war silent-undefined bei invaliden Werten: `accountId=abc`
auf `GET /contracts/:id/emails` ergab "kein Filter" → Mailbox-
Isolation brach (alle Postfächer sichtbar). Pentester R92: per
Design sind Vertrags-Endpunkte immer pro Postfach, also strict-400.
- Fix: `parsePositiveIntQuery(v, label, res, { required? })`
ersetzt den alten silent-Helper. Modes:
- default (optional): fehlend/leer → `undefined` (kein Filter),
invalid → 400
- `{ required: true }`: fehlend/leer **oder** invalid → 400
- Verteilung:
- Contract-Emails, Contract-Folder-Counts: `{ required: true }`
- Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox-
View ist legitim), invalid → 400
- Frontend hat schon ein `enabled: !!selectedAccountId`-Guard auf
den Vertrags-Queries kein UX-Bruch.
- [x] **🔒 Pentest R91 NaN-Bypass auf accountId-Query-Param**
- R91.1 (LOW): `accountId=abc``parseInt('abc')` = `NaN` → der
Ternary im Controller gab `NaN` an den Service, `if (NaN)` ist
falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit
ungültigem `accountId` sah alle Mailbox-Mails für seinen Vertrag
statt nur die aus dem gewählten Postfach (kein Cross-Customer-
Leak — `canAccessContract` greift weiter).
- Fix: zentraler `parsePositiveIntParam()` im `cachedEmail.controller.ts`,
der nur positive Ganzzahlen aus dem Query-String akzeptiert und
alles andere zu `undefined` macht. Eingesetzt in allen 5
Endpunkten, die `accountId`/`contractId` aus Query nehmen
(Contract-Emails, Contract-Folder-Counts, Customer-Emails,
Trash, Trash-Count) auch da, wo der Pentester nicht getestet
hat, weil derselbe Pattern überall stand.
- [x] **🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht**
- Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im
Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN Postfächern
des Kunden angezeigt, egal welches Postfach im Selector aktiv war.
Im Vertrag fehlte zusätzlich der Vertrags-Filter für den Papierkorb.
- Backend:
- `getEmailsForContract` controller akzeptiert jetzt `accountId`-
Query-Param und reicht ihn als `stressfreiEmailId` an
`getCachedEmails` weiter (der hat den Filter eh schon implementiert,
nur niemand hat ihn aufgerufen).
- `getTrashEmails` (controller + service) akzeptiert `accountId` und
`contractId` als optionale Filter. Default-Verhalten unverändert,
wenn keiner gesetzt ist.
- `getFolderCountsForContract` akzeptiert optional `stressfreiEmailId`,
bekommt zusätzlich `trash` + `trashUnread` ins Result sonst läge
der Trash-Badge im Vertrag wieder account-global, während die Liste
contract-scoped ist.
- Frontend:
- `cachedEmailApi.getForContract` / `getTrash` / `getContractFolderCounts`
nehmen den Filter entgegen.
- `ContractEmailsSection` reicht `selectedAccountId` in alle drei
Queries durch und nimmt es in den queryKey mit auf sonst greift
der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge
kommt jetzt aus den contract-scoped Counts, damit Badge und Liste
synchron laufen.
- `EmailClientTab` reicht `selectedAccountId` in die Trash-Query
durch (Inbox/Sent waren schon korrekt).
- [x] **🔒 Pentest R89 Provider-Adressfelder härten**
- R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)`
statt 400 501+ Zeichen wurden auf 500 abgeschnitten und mit
200 OK gespeichert.
- R89.2 (LOW): `stripHtml` lief vor dem Length-Check `<script>…</script>`
reduzierte auf leeren String → `null` in der DB → vorheriger Wert
silent überschrieben (R87.1-Pattern auf Adress-Feldern).
- Fix: eigener `validateProviderAddress()` in `sanitize.ts`. Raw-Input,
max 500 → `ApiError(400)`, Blacklist `<`, `>`, Tab, alle Control-
Chars außer `\n`. CRLF → LF normalisiert vor Length-Check.
Eingehängt in `stripProviderStrings`.
- R89.3 (Quotes) + R89.4 (`\n`): bewusst nicht gefixt Pentester
bestätigt "kein unmittelbares Risiko", React escaped korrekt,
sind legitime Bestandteile mehrzeiliger Postadressen.
- Doku in `SECURITY-HARDENING.md § Runde 89`.
- [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten**
- Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`,
`contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`,
`cancellationFax`, `cancellationAddress`. Postadressen als `TEXT`
(mehrzeilig), Rest `VARCHAR(191)`. Migration
`20260621100000_provider_contact_and_cancellation` mit `IF NOT EXISTS`.
- Modal „Anbieter bearbeiten" bekommt eine neue Sektion **Kontakt &
Kündigung** unterhalb der Auto-Login-Felder, getrennt in zwei
Untergruppen (Kontakt / Kündigung) mit kleinen Headern.
Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen
als `<textarea rows={3}>` mit `maxLength={500}`.
- Backend-Validierung: contactEmail/cancellationEmail laufen durch
`isValidEmail` (Header-Injection-Schutz für Mail-Templates),
contactPhone/contactFax/cancellationFax durch `sanitizePhoneField`
(kein CRLF/Control-Char), Postadressen durch `sanitizeNotes` mit
500-Cap.
- Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen
Felder beim Backup/Restore verloren.
- [x] **🔒 Pentest R87 Whitelist vor Sanitizer (silent-mutation-Schutz)**
- R87.1 (LOW): `stripHtml` lief im R86-Fix VOR der Whitelist.
Tags wurden still weggestrippt → 200 OK mit mutierten Werten,
`<script>…</script>` reduzierte auf leeren String → `null` in
der DB → vorheriger Wert ohne Fehlermeldung überschrieben.
- Fix: Validierungs-Reihenfolge für die fünf Identifier-Felder
umgedreht `validateContractIdentifier` läuft jetzt direkt
gegen den Raw-Input. Die strikte Whitelist lehnt eh alles
ab, was stripHtml normalerweise auffangen würde (Tags,
Schemes, Zero-Width, Homoglyphe, Percent-Encoding) Defense-
in-Depth bleibt, nur ehrlich (400 statt silent-200).
- Single-Line-Patch in `contract.controller.ts`, Doku in
`SECURITY-HARDENING.md § Runde 87`.
- [x] **🔒 Pentest R86 Vertrags-Identifier härten**
- R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/
Auftragsnummer warfen 500 (DB-Overflow `VARCHAR(191)`) statt 400.
- R86.2 (LOW/INFO): Attribut-Injection ohne umschließenden Tag
(`foo" onerror=…`) überlebte `stripHtml` kein Risiko in der React-
UI, aber relevant für PDF/Mail/CSV-Export.
- Fix: zentraler `validateContractIdentifier()` in `sanitize.ts`
mit Max-100 und Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$`. Bewusst
literales Space statt `\s`, damit kein CRLF/Tab passiert (Header-
Injection). Wirft `ApiError(400, …)` mit klarer Meldung.
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
fünf Identifier-Felder bei Create/Update. ContractForm bekommt
`maxLength={100}` als UX-Schicht. Doku in
`docs/SECURITY-HARDENING.md` § Runde 86.
- [x] **🆕 Vertrag: Auftragsnummer bei Vertriebsplattform**
- Neues optionales Feld `Contract.orderNumberAtSalesPlatform`
(`VARCHAR(191) NULL`), Migration
`20260619100000_contract_order_number_at_sales_platform` mit
`IF NOT EXISTS`.
- Im ContractForm direkt **vor** der Kundennummer der
Vertriebsplattform angeordnet (Wunsch des Users).
ContractDetail zeigt sie als eigene Zeile mit Copy-Button vor
den anderen beiden Sales-Platform-Feldern.
- Audit-Log-Mapping, Renewal-Copy (VVL-Folgevertrag) und
XSS-Strip-Allowlist (`CONTRACT_DISPLAY_STRING_FIELDS`)
mitgezogen, damit das neue Feld die gleichen Garantien wie
Kunden-/Vertragsnummer bekommt.
- [x] **🐞 Entfernte Weiterleitungen kamen via Auto-Import zurück**
- Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kürzer →
Plesk-Sync läuft → Auto-Import (`Pentest 83.x`) sieht „c ist in
Plesk aber nicht in DB" → schreibt `c` zurück in
`additionalForwardingEmails` → Diff sagt nichts zu entfernen.
- Ursache: Auto-Import war für **alle** Sync-Aufrufe aktiv. Beim
Sync-Button-Klick will der User Plesk-Bestand übernehmen (Import
sinnvoll), beim Add/Remove im Modal ist die DB-Liste die
explizite Intent (Import schädlich).
- Fix: `syncForwardingForEmail(id, { autoImportPleskMembers? })`
mit Default `true`. `setAdditionalForwards` ruft mit
`false` auf → entfernte Adressen verschwinden jetzt sauber bei
Plesk. Sync-Button-Pfad bleibt unverändert (importiert weiterhin
alte Bestands-Members).
- [x] **🐞 Plesk-Sync: `-forwarding-addresses set:` existiert gar nicht**
- Folge-Bug nach `a83358b`/`24e152b`: Sync verändert Plesk weiterhin
nicht. `plesk bin mail --help` zeigt: `-forwarding-addresses`
akzeptiert ausschließlich `add:` und `del:` unser `set:` wurde
von Plesk silent verworfen. Außerdem gibt es keine separate
`-mailgroup`-Option; was Plesk im `--info` als `Mailgroup: true`
zeigt, ist genau das, was `-forwarding true` in der CLI setzt
(doppelt benannt). Mein vorheriges `-mailgroup false` lief auf
den Phantom-Parameter und triggerte `Unrecognized option`.
- `updateForwardTargets` baut jetzt den Diff: aktuelle Mailgroup-
Members (aus `emailExists`) gegen Soll-Liste; `del:<entfernt>` +
`add:<neu>` in zwei separaten CLI-Calls. Idempotent.
Case-insensitive `Bruns.Gerhard``bruns.gerhard`.
- Phantom-`-mailgroup`-Parameter entfernt.
- Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
nichts entfernt, nur `bzirks@gmx.de` hinzugefügt.
- [x] **🔒 Pentest 83.1-83.3: Auto-Import-Pfad härten**
- **83.1 MEDIUM:** Auto-Import in `syncForwardingForEmail` umging
`assertValidForwardingEmail`. Plesk-Member wie `attacker@plesk.internal`
oder `evil@x.local` wären ohne TLD-Block-Check (71.1) in unsere
DB gewandert. Fix: jeder importierte Member läuft durch
`assertValidForwardingEmail`; ungültige werden silent gedroppt
und auf `console.debug`-Level geloggt.
- **83.2 LOW:** Self-Forward-Schutz (81.1) lief nur im Add-Pfad.
Wenn Plesk die eigene Adresse als Mailgroup-Member führte, wäre
sie beim Auto-Import in die DB-Liste gerutscht → nach dem
Umschalten auf Forwarding Mail-Loop. Fix:
`seenKeys.add(canonicalEmailKey(stressfreiEmail.email))` vor
der Import-Schleife.
- **83.3 INFO:** PII-Log auf `console.debug` umgestellt (statt
`console.log` auf Default-Level).
- Smoke-Test mit gemischter Plesk-Liste: `karibik61@web.de` (legit)
importiert, `attacker@plesk.internal` + `evil@x.local` per 83.1
abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2
abgelehnt, Customer-Stamm-Mail + Default deduped.
- [x] **🐞 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht**
- Prod-Bug: User trägt zusätzliche Weiterleitung ein, Toast meldet
Erfolg, aber Plesk übernimmt nichts. Ursache: Plesk hat zwei
Verteil-Mechanismen, **Mailgroup** (alte CLI-Anlagen,
`Group member(s):`) und **Forwarding** (`Forward request:`). Unser
Sync schrieb nur in Forwarding, die Adresse lief aber via Mailgroup
→ unsere `set:`-Befehle landeten in einer ungenutzten Tabelle.
Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt
wurden (Forwarding-Modus von Anfang an).
- `EmailExistsResult` um `mailgroupActive` + `mailgroupMembers` +
`forwardingActive` + `forwardingTargets` erweitert.
- `pleskProvider.emailExists` parst alle vier Felder aus dem
`--info`-stdout (`Mailgroup: true|false`, `Group member(s): ...`,
`Forward request: ...`).
- `pleskProvider.updateForwardTargets` setzt jetzt zusätzlich
`-mailgroup false`, damit der Legacy-Mechanismus deaktiviert wird
und nur noch Forwarding aktiv ist.
- `syncForwardingForEmail`: vor dem Plesk-Update werden bestehende
Mailgroup-Members + Forwarding-Targets abgeholt und in unsere
`additionalForwardingEmails`-Liste **importiert** (canonical-Key-
Dedup). Verlustfrei kein bestehender Empfänger fällt beim
Umschalten auf Forwarding raus. Import-Fehler werden geloggt,
aber der eigentliche Sync läuft trotzdem.
- [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/...`
auf die echte ID 4 statt 400 zurückzuliefern. Gleiches gilt für
`4.0` und Exp-Notation `4e1`.
- Fix: vorm Parsen Regex `/^\d+$/` auf die rohe `req.params.<name>`-
Eingabe. Nur reine Ziffern erlaubt keine Floats, Exp, Vorzeichen,
Whitespace, Hex.
- Smoke-Test (17 Cases): `4.0`, `4.5`, `4e1`, `4E2`, `0`, `-4`, `+4`,
`0x10`, `1.0e0`, leading/trailing Space alle abgelehnt; `1`, `4`,
`100`, `9999999` durchgewunken.
- [x] **🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt**
- Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
anlegen (siehe Screenshot mit 2× `max.mustermann@...`). `createEmail`
hatte keinen Duplikatscheck, `updateEmail` ebenfalls nicht.
- Service: Vor `prisma.create` jetzt `findFirst` auf
`(customerId, email)` (case-insensitive). Bei Treffer → `ApiError(409)`.
Unterschiedliche Meldung für aktive vs. inaktive Duplikate
(Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
- `updateEmail`: gleicher Check beim Umbenennen, mit `NOT id`-Exclude.
- Controller: `catch`-Blöcke honorieren jetzt den `ApiError.statusCode`
(vorher pauschal 400) → 409 kommt sauber durch.
- Frontend: `updateMutation` bekam ein `onError`, damit der 409 nicht
nur ins Leere lief.
- [x] **🔒 Pentest 71.171.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**
- Im „Adresse hinzufügen"-Modal erscheint der „Weitere
Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
anlegen" angehakt ist. Liste wird lokal gepflegt, Provider-Sync
läuft direkt nach `createEmail` mit der vollen Liste.
- Sub-Modal generalisiert: `value`/`onChange`-Pattern (controlled).
Mit `email`-Prop → API-Persist pro Änderung (Edit). Ohne `email`
→ lokaler State (Create). Counter-Badge am Button zeigt die
Anzahl Adressen.
- [x] **🆕 Stressfrei-Wechseln-Adressen: zusätzliche Weiterleitungsziele**
- Neues Feld `StressfreiEmail.additionalForwardingEmails` (Text/
JSON-Array), Migration `20260608100000_stressfrei_email_additional_forwards`
mit `IF NOT EXISTS`.
- `syncForwardingForEmail` zieht die zusätzlichen Adressen mit
in die Plesk-`set:`-Liste ein (case-insensitive Dedup gegen
`customer.email` und `config.defaultForwardEmail`).
- Neuer Endpoint `PUT /api/stressfrei-emails/:id/additional-forwards`
mit Body `{ emails: string[] }` ersetzt die Liste und syncht
direkt mit dem Provider. Hard-Cap 20 Adressen, Format-Check per
Regex, Audit-Log.
- Im StressfreiEmailModal neuer „Weitere Weiterleitungen"-Button
(Edit-Modus + `providerStatus === exists`) öffnet ein Sub-Modal
mit Liste + Add/Remove. Jede Änderung geht sofort live.
- [x] **🐞 Modal-Felder ließen sich nicht editieren (Zähler/Bankkarte/Ausweis/Zählerstand)**
- Vier identische Vorkommen desselben Anti-Patterns wie beim
AddressModal-Fix von 2026-06-03: `setFormData(getInitialFormData())`
im Render-Body, getriggert durch `formData.X !== prop.X`. Jeder
Tastendruck setzte den State zurück.
- Fix in allen vier Modals (MeterModal, BankCardModal,
IdentityDocumentModal, MeterReadingModal): nach `useEffect` mit
`[<entity>?.id]`-Dependency umgezogen.
- [x] **🐞 JpgToPdfModal: PDF blieb trotz vorherigem Fix bei 20+ MB**
- Stage-Test: 2 Handy-JPGs → 23 MB PDF. Ursache: Smartphone-Fotos
haben 4000-6000 px Kante (24 MP), das vergrößert die JPEG-Datei
auch ohne Re-Encode auf 5-10 MB pro Bild.
- Fix: Bilder **beim Hinzufügen** auf max. 2400 px lange Kante
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
Quality 0.92 (Lightroom-Default, kein wahrnehmbarer Unterschied)
persistiert. Vorschau-Thumbnail, Rotation/Flip und finaler
PDF-Embed laufen alle auf dem skalierten Bild.
- Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF (statt 23 MB).
- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
- `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
`ExternalLink`-Icon. CustomerDetail übergibt den Builder mit
`?tab=<id>`-Query-Param, der eh schon vom URL-Sync genutzt wird.
- `target="_blank"` + `rel="noopener noreferrer"` + Klick-stopPropagation,
damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.
- [x] **🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform**
- Zwei neue optionale Felder
`Contract.customerNumberAtSalesPlatform` +
`contractNumberAtSalesPlatform`, Migration
`20260603150000_contract_sales_platform_numbers` mit
`IF NOT EXISTS`.
- Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim
Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen
mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip
(CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
- Bonus: das fehlende `contractNumberAtProvider` im Renewal-Copy
und Audit-Label-Mapping ist gleich mit drin wurde bisher
nicht in VVL-Folgeverträge kopiert.
- [x] **🆕 Email-Links öffnen im neuen Tab**
- In `EmailDetail` nach der DOMPurify-Sanitize jedes `<a>`-Element
auf `target="_blank"` + `rel="noopener noreferrer"` gesetzt. Letzteres
verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen
in einem `useMemo`, das nur bei Wechsel der Email neu rechnet.
- [x] **🐞 assertSafePdf: jspdf-PDFs mit JPEGs fälschlich als „JavaScript" blockiert**
- Stage-Bug: User lädt Ausweis als „JPGs → PDF" hoch → 415 mit
Meldung „PDF enthält JavaScript-Action". Backend hat den jspdf-
Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream
enthielten zufällig die Byte-Folge „/JavaScript" → Pattern-Match.
- Fix: vor dem Pattern-Scan `stream..endstream`-Blöcke aus dem
PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb
von Streams (in PDF-Object-Dictionaries) Binär-Streams enthalten
Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
- Smoke-Test: jspdf-Style-PDF mit `/JavaScript`-Bytes im Stream
durchgewinkt, echte `/OpenAction /S /JavaScript` weiterhin
blockiert, clean PDF weiterhin OK.
- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
- `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
`formData.street !== address.street`. Jeder Tastendruck löste neu
aus → Reset auf DB-Wert → Cursor sprang zurück → keine Eingabe
möglich.
- Fix: in `useEffect` mit `[address?.id]`-Dependency umgezogen.
Re-Init nur beim Wechsel/Open, nicht bei jedem Render.
- [x] **🐞 Upload-Limit: Multer 10 MB → 25 MB (Ausweis-Scans, JPGs→PDF)**
- Zwei Smartphone-Fotos zu PDF kombiniert kratzten am 10-MB-Limit
(auch mit Original-Bytes-Optimierung aus 431792e). Limits in
`upload.routes.ts`, `gdpr.routes.ts`, `contract.routes.ts` auf
25 MB hochgezogen. `pdfTemplate.routes.ts` war eh schon bei 20 MB.
- Frontend: `FileUpload`-Hinweis „max. 10 MB" → „max. 25 MB".
- [x] **🆕 SIM-Karten: Checkbox „eSIM" zwischen „Hauptkarte" und „Multisim"**
- Hardware-Plastikkarte vs. eSIM-Profil ist eine eigene Eigenschaft
eSIM kann sowohl Hauptkarte als auch Multisim sein, also zusätzlich
statt entweder/oder.
- Schema: `SimCard.isEsim Boolean @default(false)`, Migration
`20260603100000_sim_card_esim` mit `IF NOT EXISTS`.
- Backend: alle vier SimCard-Schreibpfade in `contract.service.ts`
(Create + Update + Follow-Up + Renewal) plus FE-Type-Definition.
- UI: dritte Checkbox in `ContractForm` zwischen Hauptkarte und
Multisim. ContractDetail zeigt blauen `eSIM`-Badge neben Hauptkarte.
- [x] **🆕 JpgToPdfModal: PDF-Größe drastisch reduziert (Original-Bytes + Quality 0.95)**
- Stage-Bug: 2 Handy-JPGs à 2 MB → PDF >10 MB → Multer 413. Ursache:
Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB
auf (Quality 100 % heißt nicht „identisch zum Original", sondern
„möglichst viele Bits pro Pixel" ein schon JPEG-komprimiertes
Smartphone-Foto wird so künstlich 4-8× größer).
- **Fix 1:** Wenn Rotation/Flip unverändert (Standardfall), wird die
Original-DataURL 1:1 in die PDF eingebettet kein Canvas-Roundtrip,
keine Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Funktioniert für
JPEG und PNG (Format-Detection per `data:image/png`-Prefix).
- **Fix 2:** Bei Transformation: `toDataURL('image/jpeg', 0.95)` statt
`1.0`. Visuell identisch für Foto-Inhalte (Adobe-Lightroom-Default),
aber 50-70 % kleiner.
- Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
16-30 MB), 2 gedrehte ≈ 5-8 MB.
- [x] **🔒 Pentest 70.2 (LOW): falscher 500 statt 415 bei verbotenem MIME-Type**
- Globaler Error-Handler in `index.ts:461` matcht
`/sind erlaubt|nicht erlaubt/i` und mappt auf 415. Meine 70.1-
Message „… WebP erlaubt" (ohne „sind") rutschte durch und landete
bei 500 + Error-Log-Spam.
- Fix: 1 Zeile in `contract.routes.ts` `… WebP-Dateien sind
erlaubt` macht den Regex glücklich. Andere Routes
(`upload.routes.ts`, `gdpr.routes.ts`, `pdfTemplate.routes.ts`,
`factoryDefaults.routes.ts`, `appSetting.routes.ts`) nutzen alle
schon „sind erlaubt".
- [x] **🔒 Pentest 70.1 (INFO): GIF/WebP-Inkonsistenz in contract.routes Multer-Filter**
- `contract.routes.ts` Vertragsdokumente: Multer-fileFilter blockte
`image/gif` und `image/webp`, obwohl `validateUploadedFile` beide
Typen zulässt. Folge: GIF mit korrektem MIME `image/gif` → 415, GIF
mit gespooftem MIME `image/jpeg` → akzeptiert (vom Magic-Byte als
`.gif` erkannt). Kein Sicherheitsproblem (Magic-Byte ist der echte
Guard), aber inkonsistent.
- Fix: Whitelist um `image/gif` + `image/webp` ergänzt → konsistent
zum zentralen `validateUploadedFile` und zu `upload.routes.ts`.
- [x] **🔒 Pentest 69.3 (INFO → Defense-in-Depth): Magic-Byte-Check auf Vertragsdokumente erweitert**
- `contract.routes.ts` Vertragsdokumente-Upload hatte bisher nur den
PDF-Inhalts-Scan (`scanUploadedPdfIfPresent` aus 68.1). JPG/PNG-
Uploads waren ungeprüft kompensiert durch Download-Layer
(`fileDownload.controller.ts` liefert nur bei Magic-Byte-Match
inline aus, sonst attachment). Pentester selbst: "ohne Exploit-
Pfad", aber inkonsistent zu `upload.routes.ts`.
- **Refactor:** `detectType` + `validateUploadedFile` aus
`upload.routes.ts` in neue Middleware
`middleware/uploadFileTypeValidator.ts` ausgelagert (Single Source
of Truth). Beide Routes nutzen jetzt denselben Helper.
- **contract.routes.ts:** `validateUploadedFile` ersetzt das
schlankere `scanUploadedPdfIfPresent` jetzt greift Magic-Byte +
canonical Rename + PDF-Scan für Vertragsdokumente analog zu allen
anderen Upload-Pfaden.
- **pdfUploadSafety.ts:** `scanUploadedPdfIfPresent` entfernt (tot,
da nur in contract.routes verwendet wurde). `requireSafeUploadedPdf`
bleibt für gdpr.routes Vollmacht + pdfTemplate.routes.
- [x] **🔒 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Inhalts-Validierung + Modal-Limit**
- **68.1 PDF-Active-Content-Filter:** Magic-Byte-Check prüfte bisher
nur `%PDF-`. PDFs mit `/JavaScript`, `/JS`, `/Launch` (externes
Programm), `/EmbeddedFile`, `/RichMedia` (Flash) wurden inline an
den Viewer ausgeliefert Browser-PDF-Viewer (Chrome/Firefox)
ignorieren JS, Adobe Acrobat aber nicht.
- Neuer Helper `assertSafePdf(buf)` in `utils/sanitize.ts`:
String-Scan auf die fünf Action-Pattern (case-sensitive nach
PDF 32000-1:2008 §7.3.5). Wirft `ApiError(415, ...)` bei Treffer.
- Neue Middleware `pdfUploadSafety.ts` mit zwei Varianten:
- `requireSafeUploadedPdf` Datei MUSS PDF sein, sonst 415.
- `scanUploadedPdfIfPresent` durchwinkt JPG/PNG, scannt nur PDFs.
- Eingehängt:
- `upload.routes.ts` (Magic-Byte-Validator erweitert)
- `gdpr.routes.ts` Vollmacht-Upload
- `pdfTemplate.routes.ts` Template-Upload
- `contract.routes.ts` Vertragsdokumente
- `cachedEmail.controller.ts` Email-Anhang-Pfade (3 Stellen:
saveAttachmentTo, saveAttachmentAsInvoice,
saveAttachmentAsContractDocument)
- **Inline-Vorschau bleibt erhalten** das war die explizite
Anforderung (Augen-Button öffnet PDF im neuen Tab). Pentester-
Empfehlung „disposition=inline abschalten" wurde bewusst NICHT
umgesetzt, weil sie das eigentliche Acrobat-Risiko nicht löst
(PDF auf Disk + Doppelklick → Acrobat → JS läuft trotzdem).
- Edge-Case-Test bestätigt: `/JSXForm` und `/JavaScriptFooter` werden
NICHT als JavaScript-Action erkannt (word-boundary `\b` greift).
- **68.2 Modal-Limit:** `JpgToPdfModal` hatte kein Bild-/Größen-Limit.
Jetzt `MAX_IMAGES = 50` + `MAX_IMAGE_BYTES = 25 MB` pro Bild.
UX-Schutz, kein Security-Bug (Self-DoS only).
- [x] **🆕 JPGs → PDF: Button überall bei PDF-Upload**
- Neue Komponente `JpgToPdfModal` (lokal im Browser via `jspdf`,
keine Backend-Round-Trip nötig). Mehrere Bilder hinzufügen per
Klick, Drag&Drop oder `Strg+V` (Clipboard-Image), Reihenfolge
per Drag&Drop sortierbar, pro Bild 90°/180°-Drehung +
Horizontal/Vertikal-Spiegelung. Quality 100%, 1 Bild = 1 Seite,
A4 mit automatischer Hoch-/Querformat-Wahl je Bild.
- `FileUpload`-Komponente (11 Stellen: Datenschutz-PDF,
Vollmacht, Bankkarten-Dokumente, Ausweise, Gewerbeanmeldung,
Handelsregister, Kündigungsschreiben + -Bestätigung +
deren Optionen) bekommt automatisch einen sekundären
"JPGs → PDF"-Button, wenn `accept` PDF einschließt.
- Direkt-Inputs ebenfalls erweitert: Vertragsdokumente
(ContractDetail), Vollmacht-Dokumente (CustomerDetail Tab),
Rechnungen (InvoicesSection).
- PdfTemplates **bewusst ausgenommen** braucht echte
AcroForm-PDFs mit Formularfeldern, Bild-PDFs wären unbrauchbar.
- [x] **🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"**
- `customerEmailLabel` existierte im Backend (Schema +
Update-Logik + Public-Endpoint), war im UI aber nicht
+190 -2
View File
@@ -1,12 +1,12 @@
{
"name": "opencrm-frontend",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencrm-frontend",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"@tiptap/extension-link": "^3.19.0",
@@ -15,6 +15,7 @@
"@tiptap/starter-kit": "^3.19.0",
"axios": "^1.7.7",
"dompurify": "^3.4.1",
"jspdf": "^4.2.1",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -264,6 +265,14 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1632,11 +1641,22 @@
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"optional": true
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
@@ -1767,6 +1787,15 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz",
@@ -1874,6 +1903,25 @@
}
]
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1936,11 +1984,31 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2169,6 +2237,16 @@
"node": ">= 6"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -2178,6 +2256,11 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2370,6 +2453,24 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2465,6 +2566,22 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2667,12 +2784,23 @@
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3075,6 +3203,15 @@
}
]
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -3189,6 +3326,12 @@
"node": ">=8.10.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"optional": true
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3219,6 +3362,15 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
@@ -3317,6 +3469,15 @@
"node": ">=0.10.0"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -3351,6 +3512,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -3388,6 +3558,15 @@
"node": ">=14.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -3534,6 +3713,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@tiptap/starter-kit": "^3.19.0",
"axios": "^1.7.7",
"dompurify": "^3.4.1",
"jspdf": "^4.2.1",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1,7 +1,8 @@
import { useState, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { formatDate } from '../../utils/dateFormat';
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye } from 'lucide-react';
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye, Images } from 'lucide-react';
import JpgToPdfModal from '../ui/JpgToPdfModal';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import Input from '../ui/Input';
@@ -218,6 +219,7 @@ function InvoiceModal({
});
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
const addInvoiceFn = async (data: { invoiceDate: string; invoiceType: string; notes?: string }) => {
if (ecdId) {
@@ -386,6 +388,7 @@ function InvoiceModal({
onChange={handleFileSelect}
className="hidden"
/>
<div className="flex gap-2 flex-wrap">
<Button
type="button"
variant="secondary"
@@ -393,8 +396,23 @@ function InvoiceModal({
>
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setIsJpgModalOpen(true)}
title="Mehrere JPGs zu einer PDF kombinieren"
>
<Images className="w-4 h-4 mr-1" /> JPGs PDF
</Button>
</div>
</div>
)}
<JpgToPdfModal
isOpen={isJpgModalOpen}
onClose={() => setIsJpgModalOpen(false)}
onComplete={(file) => setSelectedFile(file)}
fileNameHint="rechnung"
/>
{formData.invoiceType === 'NOT_AVAILABLE' && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { FileText, Loader2, ExternalLink } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { contractApi, EmailAttachment } from '../../services/api';
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
import { viewUrl } from '../../utils/fileUrl';
interface Props {
isOpen: boolean;
onClose: () => void;
contractId: number;
currentAttachments: EmailAttachment[];
onAttach: (added: EmailAttachment[]) => void;
}
const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // identisch zur Compose-Modal
export default function AttachContractDocumentsModal({
isOpen,
onClose,
contractId,
currentAttachments,
onAttach,
}: Props) {
const [selected, setSelected] = useState<Set<number>>(new Set());
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract-documents', contractId],
queryFn: () => contractApi.getDocuments(contractId),
enabled: isOpen,
});
const documents = data?.data || [];
const toggle = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleClose = () => {
if (busy) return; // Kein Abbruch während Download läuft
setSelected(new Set());
onClose();
};
const handleAttach = async () => {
if (selected.size === 0) {
handleClose();
return;
}
setBusy(true);
const docsToFetch = documents.filter((d) => selected.has(d.id));
const newAttachments: EmailAttachment[] = [];
let runningSize = totalAttachmentBytes(currentAttachments);
try {
for (const doc of docsToFetch) {
try {
const att = await serverFileToAttachment(doc.documentPath, doc.originalName);
const approxBytes = Math.ceil(att.content.length * 0.75);
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
toast.error(
`Maximale Gesamtgröße erreicht (25 MB). "${doc.originalName}" und folgende übersprungen.`,
{ duration: 6000 },
);
break;
}
newAttachments.push(att);
runningSize += approxBytes;
} catch (err: any) {
toast.error(err?.message || `Fehler beim Anhängen von "${doc.originalName}"`);
}
}
if (newAttachments.length > 0) {
onAttach(newAttachments);
toast.success(
newAttachments.length === 1
? '1 Dokument angehängt'
: `${newAttachments.length} Dokumente angehängt`,
);
}
setSelected(new Set());
onClose();
} finally {
setBusy(false);
}
};
// Nach documentType gruppieren für übersichtliche Darstellung
const grouped = documents.reduce<Record<string, typeof documents>>((acc, doc) => {
const key = doc.documentType || 'Sonstiges';
if (!acc[key]) acc[key] = [];
acc[key].push(doc);
return acc;
}, {});
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Vertragsdokumente anhängen"
size="lg"
>
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-500">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Dokumente werden geladen
</div>
) : documents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<FileText className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">Keine Dokumente am Vertrag hinterlegt</p>
</div>
) : (
<div className="space-y-4 max-h-96 overflow-y-auto">
{Object.entries(grouped).map(([type, docs]) => (
<div key={type}>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
{type}
</div>
<div className="space-y-1">
{docs.map((doc) => (
<div
key={doc.id}
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50"
>
<label className="flex items-start gap-2 flex-1 min-w-0 cursor-pointer">
<input
type="checkbox"
checked={selected.has(doc.id)}
onChange={() => toggle(doc.id)}
disabled={busy}
className="mt-0.5 rounded"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="truncate">{doc.originalName}</span>
</div>
{doc.notes && (
<div className="text-xs text-gray-500 mt-0.5 ml-6 truncate">
{doc.notes}
</div>
)}
</div>
</label>
<a
href={viewUrl(doc.documentPath)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
title="Dokument in neuem Tab öffnen"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Vorschau</span>
</a>
</div>
))}
</div>
</div>
))}
</div>
)}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
{selected.size > 0 ? `${selected.size} ausgewählt` : 'Keine Auswahl'}
</span>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
onClick={handleAttach}
disabled={busy || selected.size === 0}
>
{busy ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Anhängen
</>
) : (
'Anhängen'
)}
</Button>
</div>
</div>
</div>
</Modal>
);
}
@@ -1,10 +1,12 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, FileText } from 'lucide-react';
import { Send, Paperclip, X, FileText, FilePlus, UserPlus } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
import { useMutation } from '@tanstack/react-query';
import AttachContractDocumentsModal from './AttachContractDocumentsModal';
import InsertCustomerDataModal from './InsertCustomerDataModal';
interface ComposeEmailModalProps {
isOpen: boolean;
@@ -31,6 +33,8 @@ export default function ComposeEmailModal({
const [body, setBody] = useState('');
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
const [error, setError] = useState<string | null>(null);
const [showAttachDocsModal, setShowAttachDocsModal] = useState(false);
const [showInsertDataModal, setShowInsertDataModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Formular bei Modal-Öffnung initialisieren
@@ -308,7 +312,8 @@ export default function ComposeEmailModal({
className="hidden"
/>
{/* Anhang hinzufügen Button */}
{/* Anhang-/Daten-Buttons */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
@@ -317,6 +322,29 @@ export default function ComposeEmailModal({
<Paperclip className="w-4 h-4 mr-2" />
Datei anhängen
</button>
{contractId && (
<>
<button
type="button"
onClick={() => setShowAttachDocsModal(true)}
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
title="Bereits am Vertrag hinterlegte Dokumente direkt anhängen"
>
<FilePlus className="w-4 h-4 mr-2" />
Vertragsdokumente
</button>
<button
type="button"
onClick={() => setShowInsertDataModal(true)}
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
title="Kunden- und Vertragsdaten in die Nachricht einfügen, optional Ausweis/Bankkarte als PDF anhängen"
>
<UserPlus className="w-4 h-4 mr-2" />
Kundendaten einfügen
</button>
</>
)}
</div>
{/* Anhang-Liste */}
{attachments.length > 0 && (
@@ -374,6 +402,35 @@ export default function ComposeEmailModal({
</Button>
</div>
</div>
{/* Sub-Modal: Vertragsdokumente anhängen */}
{contractId && (
<AttachContractDocumentsModal
isOpen={showAttachDocsModal}
onClose={() => setShowAttachDocsModal(false)}
contractId={contractId}
currentAttachments={attachments}
onAttach={(added) => setAttachments((prev) => [...prev, ...added])}
/>
)}
{/* Sub-Modal: Kundendaten einfügen */}
{contractId && (
<InsertCustomerDataModal
isOpen={showInsertDataModal}
onClose={() => setShowInsertDataModal(false)}
contractId={contractId}
senderEmail={account.email}
currentBody={body}
currentAttachments={attachments}
onResult={(newBody, addedAtt) => {
setBody(newBody);
if (addedAtt.length > 0) {
setAttachments((prev) => [...prev, ...addedAtt]);
}
}}
/>
)}
</Modal>
);
}
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/
import { useAuth } from '../../context/AuthContext';
import Button from '../ui/Button';
import Card from '../ui/Card';
import CopyButton from '../ui/CopyButton';
import EmailDetail from './EmailDetail';
import ComposeEmailModal from './ComposeEmailModal';
import TrashEmailList from './TrashEmailList';
@@ -49,28 +50,44 @@ export default function ContractEmailsSection({
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
// E-Mails für den Vertrag laden (nach Ordner gefiltert, nicht für TRASH)
// E-Mails für den Vertrag laden (nach Ordner UND Postfach gefiltert).
// Bug 2026-06-21: vorher gingen Mails aus allen Postfächern in den
// gewählten Vertrags-Ordner obwohl der User ein bestimmtes Postfach
// ausgewählt hatte. selectedAccountId muss in queryKey + queryFn.
const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
queryKey: ['emails', 'contract', contractId, selectedFolder],
queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder as 'INBOX' | 'SENT' }),
enabled: selectedFolder !== 'TRASH',
queryKey: ['emails', 'contract', contractId, selectedAccountId, selectedFolder],
queryFn: () => cachedEmailApi.getForContract(contractId, {
folder: selectedFolder as 'INBOX' | 'SENT',
accountId: selectedAccountId ?? undefined,
}),
enabled: selectedFolder !== 'TRASH' && !!selectedAccountId,
});
const emails = emailsData?.data || [];
// Papierkorb-E-Mails laden (für den ganzen Kunden, da Trash nicht vertragsgebunden)
// Papierkorb-E-Mails laden jetzt strikt: nur das aktuell ausgewählte
// Postfach UND nur dem Vertrag zugeordnete Mails. Wenn man also den
// Vertrags-Papierkorb öffnet, sieht man nicht mehr alle gelöschten
// E-Mails des Kunden, sondern wirklich nur die, die diesem Vertrag
// aus diesem Postfach zugeordnet waren.
const { data: trashData, isLoading: trashLoading } = useQuery({
queryKey: ['emails', 'trash', customerId],
queryFn: () => cachedEmailApi.getTrash(customerId),
enabled: selectedFolder === 'TRASH' && canAccessTrash,
queryKey: ['emails', 'trash', customerId, selectedAccountId, contractId],
queryFn: () => cachedEmailApi.getTrash(customerId, {
accountId: selectedAccountId ?? undefined,
contractId,
}),
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
});
const trashEmails = trashData?.data || [];
// Ordner-Anzahlen für Badges (Vertrag)
// Ordner-Anzahlen für Badges (Vertrag + Postfach). Badge und Liste
// müssen mit derselben Filter-Kombination laufen, sonst zeigt der
// Badge eine andere Zahl als die sichtbare Liste.
const { data: folderCountsData } = useQuery({
queryKey: ['contract-folder-counts', contractId],
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId),
queryKey: ['contract-folder-counts', contractId, selectedAccountId],
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId, selectedAccountId ?? undefined),
enabled: !!selectedAccountId,
});
const folderCounts = folderCountsData?.data || {
@@ -78,16 +95,6 @@ export default function ContractEmailsSection({
inboxUnread: 0,
sent: 0,
sentUnread: 0,
};
// Ordner-Anzahlen für das Konto (für Trash-Badge)
const { data: accountFolderCountsData } = useQuery({
queryKey: ['folder-counts', selectedAccountId],
queryFn: () => stressfreiEmailApi.getFolderCounts(selectedAccountId!),
enabled: !!selectedAccountId && canAccessTrash,
});
const accountFolderCounts = accountFolderCountsData?.data || {
trash: 0,
trashUnread: 0,
};
@@ -358,11 +365,23 @@ export default function ContractEmailsSection({
</option>
))}
</select>
{selectedAccount?.email && (
<CopyButton
value={selectedAccount.email}
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
/>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Inbox className="w-4 h-4 text-gray-500" />
<span>{selectedAccount?.email}</span>
{selectedAccount?.email && (
<CopyButton
value={selectedAccount.email}
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
/>
)}
</div>
)}
@@ -429,18 +448,18 @@ export default function ContractEmailsSection({
>
<Trash2 className="w-4 h-4" />
Papierkorb
{accountFolderCounts.trash > 0 && (
{folderCounts.trash > 0 && (
<span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
accountFolderCounts.trashUnread > 0
folderCounts.trashUnread > 0
? 'bg-red-100 text-red-600 font-medium'
: 'bg-gray-100 text-gray-500'
}`}
title={`${accountFolderCounts.trashUnread} ungelesen / ${accountFolderCounts.trash} gesamt`}
title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`}
>
{accountFolderCounts.trashUnread > 0
? `${accountFolderCounts.trashUnread}/${accountFolderCounts.trash}`
: accountFolderCounts.trash}
{folderCounts.trashUnread > 0
? `${folderCounts.trashUnread}/${folderCounts.trash}`
: folderCounts.trash}
</span>
)}
</button>
@@ -470,9 +489,13 @@ export default function ContractEmailsSection({
)}
</div>
) : (
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
{/* Email List */}
<div className="w-1/3 border-r border-gray-200 overflow-auto">
<div
className="flex -mx-6 -mb-6"
style={{ height: '600px' }}
>
{/* Email List scrollt intern, damit die Vertrags-Seite nicht
elendig lang wird. */}
<div className="w-1/3 border-r border-gray-200 overflow-y-auto">
{selectedFolder === 'TRASH' ? (
<TrashEmailList
emails={trashEmails}
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } fr
import { useAuth } from '../../context/AuthContext';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import Button from '../ui/Button';
import CopyButton from '../ui/CopyButton';
import EmailList from './EmailList';
import EmailDetail from './EmailDetail';
import ComposeEmailModal from './ComposeEmailModal';
@@ -124,11 +125,17 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const emails = emailsData?.data || [];
// Papierkorb-E-Mails laden
// Papierkorb-E-Mails laden jetzt strikt pro Postfach.
// Bug 2026-06-21: vorher kamen alle gelöschten E-Mails des Kunden
// raus, egal welches Postfach selektiert war. selectedAccountId muss
// in queryKey + queryFn, sonst greift React-Query-Cache bei Wechsel
// nicht und der Folder-Count aus folderCountsData liefe auseinander.
const { data: trashData, isLoading: trashLoading } = useQuery({
queryKey: ['emails', 'trash', customerId],
queryFn: () => cachedEmailApi.getTrash(customerId),
enabled: selectedFolder === 'TRASH' && canAccessTrash,
queryKey: ['emails', 'trash', customerId, selectedAccountId],
queryFn: () => cachedEmailApi.getTrash(customerId, {
accountId: selectedAccountId ?? undefined,
}),
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
});
const trashEmails = trashData?.data || [];
@@ -288,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
};
return (
<div className="flex flex-col h-full" style={{ minHeight: '600px' }}>
// Bounded auf Viewport-Höhe sonst ignoriert h-full ohnehin den
// Tab-Container und der Postfach-Inhalt wächst beliebig, sodass die
// ganze Seite scrollt statt nur die E-Mail-Liste.
<div
className="flex flex-col"
style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}
>
{/* Header */}
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
{/* Account Selector */}
@@ -309,11 +322,25 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
</option>
))}
</select>
{selectedAccount?.email && (
<CopyButton
value={selectedAccount.email}
size="md"
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
/>
)}
</div>
) : (
<div className="flex items-center gap-3 text-sm text-gray-600">
<Inbox className="w-5 h-5 text-gray-500" />
<span>{selectedAccount?.email}</span>
{selectedAccount?.email && (
<CopyButton
value={selectedAccount.email}
size="md"
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
/>
)}
</div>
)}
+30 -11
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
import DOMPurify from 'dompurify';
import { CachedEmail, cachedEmailApi } from '../../services/api';
@@ -52,6 +52,24 @@ export default function EmailDetail({
setLocalStarred(email.isStarred);
}, [email.id, email.isStarred]);
// Email-Body sanitizen + alle <a>-Links auf neuen Tab umstellen.
// rel="noopener noreferrer" verhindert window.opener-Tab-Hijacking.
const safeHtmlBody = useMemo(() => {
if (!email.htmlBody) return '';
const sanitized = DOMPurify.sanitize(email.htmlBody, {
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
ADD_ATTR: ['target'],
});
const wrapper = document.createElement('div');
wrapper.innerHTML = sanitized;
wrapper.querySelectorAll('a').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
return wrapper.innerHTML;
}, [email.htmlBody]);
const toggleStarMutation = useMutation({
mutationFn: () => cachedEmailApi.toggleStar(email.id),
onMutate: () => {
@@ -312,6 +330,16 @@ export default function EmailDetail({
{email.contract.contractNumber}
</Link>
</span>
<a
href={`/contracts/${email.contract.id}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-green-600 hover:text-green-800"
title="Vertrag in neuem Tab öffnen"
aria-label="Vertrag in neuem Tab öffnen"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
{!email.isAutoAssigned && (
<button
@@ -411,16 +439,7 @@ export default function EmailDetail({
{showHtml && email.htmlBody ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(email.htmlBody, {
// Scripte, Inline-Handler, Form-Elemente, externe Referenzen verbieten.
// Bilder + Links mit target=_blank bleiben zugelassen.
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
// Links in neuen Tabs öffnen (verhindert window.opener-Angriffe)
ADD_ATTR: ['target'],
}),
}}
dangerouslySetInnerHTML={{ __html: safeHtmlBody }}
/>
) : (
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
@@ -0,0 +1,613 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { contractApi, EmailAttachment } from '../../services/api';
import { formatDate } from '../../utils/dateFormat';
import {
bankCardAttachmentName,
identityDocAttachmentName,
serverFileToAttachment,
totalAttachmentBytes,
} from './composeAttachmentHelpers';
import type { Contract, Address, BankCard, IdentityDocument } from '../../types';
interface Props {
isOpen: boolean;
onClose: () => void;
contractId: number;
/**
* E-Mail-Adresse des Postfachs, von dem die Mail abgeschickt wird.
* Wird in der "Anrede & Name"-Section als Alternative zur Stammdaten-
* E-Mail angeboten User-Wunsch 2026-06-21: bei Kundendaten wählen,
* ob die Customer-Email oder die Stressfrei-Wechseln-Absender-Adresse
* eingefügt wird.
*/
senderEmail: string;
currentBody: string;
currentAttachments: EmailAttachment[];
onResult: (newBody: string, addedAttachments: EmailAttachment[]) => void;
}
type EmailChoice = 'master' | 'sender' | 'none';
const MAX_TOTAL_SIZE = 25 * 1024 * 1024;
type SectionKey =
| 'customer'
| 'deliveryAddress'
| 'billingAddress'
| 'contract';
export default function InsertCustomerDataModal({
isOpen,
onClose,
contractId,
senderEmail,
currentBody,
currentAttachments,
onResult,
}: Props) {
const { data, isLoading } = useQuery({
queryKey: ['contract', contractId, 'for-insert-data'],
queryFn: () => contractApi.getById(contractId),
enabled: isOpen,
});
const contract = data?.data;
const customer = contract?.customer;
const deliveryAddress = contract?.address;
// Rechnungsadresse nur eigenständig zeigen, wenn sie sich tatsächlich
// von der Lieferadresse unterscheidet sonst doppelt im Text.
const billingAddress = useMemo(() => {
if (!contract?.billingAddress) return undefined;
if (!deliveryAddress) return contract.billingAddress;
return contract.billingAddress.id !== deliveryAddress.id ? contract.billingAddress : undefined;
}, [contract?.billingAddress, deliveryAddress]);
const bankCard = contract?.bankCard;
const identityDocument = contract?.identityDocument;
// Sections die default-an sind: Anrede + Vertragsdaten. Anhang-/Text-
// Schalter für Bank + Ausweis bleiben default-aus (User-Intent: bewusst
// entscheiden, was vertraulich verschickt wird).
const [checked, setChecked] = useState<Record<SectionKey, boolean>>({
customer: true,
deliveryAddress: true,
billingAddress: false,
contract: true,
});
// Bank: zwei unabhängige Schalter. Text fügt nur die letzten 4 IBAN-
// Stellen ein (kein vollständiger IBAN-Versand per Mail = Default-Hygiene).
const [insertBankText, setInsertBankText] = useState(false);
const [attachBankPdf, setAttachBankPdf] = useState(false);
// Ausweis: Text-Schalter fügt nur die Ausweisnummer ein, kein Geburtsdatum
// / keine Ausstellungsdaten falls der Empfänger nur die Nummer braucht.
const [insertIdentityText, setInsertIdentityText] = useState(false);
const [attachIdentityPdf, setAttachIdentityPdf] = useState(false);
// Welche E-Mail-Adresse in der Customer-Section steht:
// - 'master' = Stammdaten-E-Mail (customer.email)
// - 'sender' = Postfach-Adresse, von der die Mail abgeht (Stressfrei)
// - 'none' = E-Mail-Zeile weglassen
const [emailChoice, setEmailChoice] = useState<EmailChoice>('master');
const [busy, setBusy] = useState(false);
// Bei jedem Öffnen sinnvoll vorbelegen (sonst bleiben "checked" stale
// wenn das Modal mal mit anderen Daten wieder aufgeht).
useEffect(() => {
if (isOpen && contract) {
setChecked({
customer: !!customer,
deliveryAddress: !!deliveryAddress,
billingAddress: false, // nur wenn vorhanden, aber default aus
contract: true,
});
setInsertBankText(false);
setAttachBankPdf(false);
setInsertIdentityText(false);
setAttachIdentityPdf(false);
// Default: Stammdaten-E-Mail wenn vorhanden, sonst Absender-Adresse.
setEmailChoice(customer?.email ? 'master' : 'sender');
}
}, [isOpen, contract, customer, deliveryAddress]);
const toggle = (key: SectionKey) => {
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleClose = () => {
if (busy) return;
onClose();
};
const handleConfirm = async () => {
if (!contract) return;
setBusy(true);
try {
const blocks: string[] = [];
if (checked.customer && customer) {
const chosenEmail =
emailChoice === 'master'
? customer.email || ''
: emailChoice === 'sender'
? senderEmail
: '';
blocks.push(formatCustomerBlock(customer, contract, chosenEmail));
}
if (checked.deliveryAddress && deliveryAddress) {
blocks.push(formatAddressBlock('Lieferadresse', deliveryAddress));
}
if (checked.billingAddress && billingAddress) {
blocks.push(formatAddressBlock('Rechnungsadresse', billingAddress));
}
if (checked.contract) {
blocks.push(formatContractBlock(contract));
}
if (insertBankText && bankCard) {
blocks.push(formatBankBlock(bankCard));
}
if (insertIdentityText && identityDocument) {
blocks.push(formatIdentityBlock(identityDocument));
}
const textToInsert = blocks
.filter((b) => b.trim().length > 0)
.join('\n\n');
// Anhänge sammeln
const newAttachments: EmailAttachment[] = [];
let runningSize = totalAttachmentBytes(currentAttachments);
const tryAttach = async (
documentPath: string | undefined,
filename: string,
): Promise<boolean> => {
if (!documentPath) return false;
try {
const att = await serverFileToAttachment(documentPath, filename);
const approxBytes = Math.ceil(att.content.length * 0.75);
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
toast.error(`"${filename}" gesprengt das 25-MB-Anhang-Limit.`);
return false;
}
newAttachments.push(att);
runningSize += approxBytes;
return true;
} catch (err: any) {
toast.error(err?.message || `Fehler beim Anhängen von "${filename}"`);
return false;
}
};
if (attachBankPdf && bankCard?.documentPath) {
await tryAttach(
bankCard.documentPath,
bankCardAttachmentName(bankCard.iban),
);
}
if (attachIdentityPdf && identityDocument?.documentPath) {
await tryAttach(
identityDocument.documentPath,
identityDocAttachmentName(
identityDocument.type,
identityDocument.documentNumber,
),
);
}
const separator = currentBody && !currentBody.endsWith('\n') ? '\n\n' : '';
const newBody = textToInsert
? currentBody + separator + textToInsert
: currentBody;
onResult(newBody, newAttachments);
onClose();
} finally {
setBusy(false);
}
};
const nothingSelected =
!checked.customer &&
!checked.deliveryAddress &&
!checked.billingAddress &&
!checked.contract &&
!insertBankText &&
!attachBankPdf &&
!insertIdentityText &&
!attachIdentityPdf;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Kundendaten einfügen"
size="lg"
>
<div className="space-y-4">
{isLoading || !contract ? (
<div className="flex items-center justify-center py-8 text-gray-500">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Daten werden geladen
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{customer && (
<SectionRow
title="Anrede & Name"
checked={checked.customer}
onToggle={() => toggle('customer')}
preview={previewCustomer(customer, contract)}
extra={
checked.customer && (
<div className="mt-2 ml-6 space-y-1">
<div className="text-xs font-medium text-gray-600">
E-Mail im Text:
</div>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'master'}
onChange={() => setEmailChoice('master')}
disabled={!customer.email}
className="text-blue-600"
/>
<span>
Stammdaten-E-Mail
{customer.email ? (
<span className="text-gray-400"> ({customer.email})</span>
) : (
<span className="text-gray-400"> (nicht hinterlegt)</span>
)}
</span>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'sender'}
onChange={() => setEmailChoice('sender')}
className="text-blue-600"
/>
<span>
Absender-Adresse
<span className="text-gray-400"> ({senderEmail})</span>
</span>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'none'}
onChange={() => setEmailChoice('none')}
className="text-blue-600"
/>
<span>Keine E-Mail einfügen</span>
</label>
</div>
)
}
/>
)}
{deliveryAddress && (
<SectionRow
title="Lieferadresse"
checked={checked.deliveryAddress}
onToggle={() => toggle('deliveryAddress')}
preview={previewAddress(deliveryAddress)}
/>
)}
{billingAddress && (
<SectionRow
title="Rechnungsadresse"
checked={checked.billingAddress}
onToggle={() => toggle('billingAddress')}
preview={previewAddress(billingAddress)}
/>
)}
<SectionRow
title="Vertragsdaten"
checked={checked.contract}
onToggle={() => toggle('contract')}
preview={previewContract(contract)}
/>
{bankCard && (
<DualChoiceRow
title="Bankverbindung"
preview={previewBank(bankCard)}
textChecked={insertBankText}
onToggleText={() => setInsertBankText((v) => !v)}
textLabel="Letzte 4 IBAN-Stellen einfügen"
textDisabled={!lastFourIban(bankCard.iban)}
pdfChecked={attachBankPdf}
onTogglePdf={() => setAttachBankPdf((v) => !v)}
pdfLabel="Bankkarte als PDF anhängen"
pdfDisabled={!bankCard.documentPath}
/>
)}
{identityDocument && (
<DualChoiceRow
title={identityTypeLabel(identityDocument.type)}
preview={previewIdentity(identityDocument)}
textChecked={insertIdentityText}
onToggleText={() => setInsertIdentityText((v) => !v)}
textLabel={`${identityTypeLabel(identityDocument.type)}-Nummer einfügen`}
textDisabled={!identityDocument.documentNumber}
pdfChecked={attachIdentityPdf}
onTogglePdf={() => setAttachIdentityPdf((v) => !v)}
pdfLabel={`${identityTypeLabel(identityDocument.type)} als PDF anhängen`}
pdfDisabled={!identityDocument.documentPath}
/>
)}
{/* Falls weder Customer noch Address etc. da sind */}
{!customer && !deliveryAddress && !bankCard && !identityDocument && (
<p className="text-sm text-gray-500 text-center py-4">
Keine weiteren Daten am Kunden hinterlegt.
</p>
)}
</div>
)}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<span className="text-xs text-gray-500">
Text wird ans Ende der Nachricht angehängt.
</span>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
onClick={handleConfirm}
disabled={busy || isLoading || nothingSelected}
>
{busy ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Einfügen
</>
) : (
'Einfügen'
)}
</Button>
</div>
</div>
</div>
</Modal>
);
}
// ==================== UI-Helper ====================
interface SectionRowProps {
title: string;
checked: boolean;
onToggle: () => void;
preview: string;
extra?: React.ReactNode;
}
interface DualChoiceRowProps {
title: string;
preview: string;
textChecked: boolean;
onToggleText: () => void;
textLabel: string;
textDisabled?: boolean;
pdfChecked: boolean;
onTogglePdf: () => void;
pdfLabel: string;
pdfDisabled?: boolean;
}
/**
* Sections, die unabhängig Text und PDF anbieten (Bank, Ausweis).
* Keine primäre Checkbox beide Schalter wirken einzeln, deshalb
* kein "alle-ein/alle-aus" auf Section-Ebene nötig.
*/
function DualChoiceRow({
title,
preview,
textChecked,
onToggleText,
textLabel,
textDisabled,
pdfChecked,
onTogglePdf,
pdfLabel,
pdfDisabled,
}: DualChoiceRowProps) {
return (
<div className="border border-gray-200 rounded-lg p-3">
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="text-xs text-gray-500 mt-1">{preview}</div>
<div className="mt-2 space-y-1">
<label className={`flex items-center gap-2 text-xs cursor-pointer ${textDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
<input
type="checkbox"
checked={textChecked}
onChange={onToggleText}
disabled={textDisabled}
className="rounded"
/>
<span>{textLabel}</span>
</label>
<label className={`flex items-center gap-2 text-xs cursor-pointer ${pdfDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
<input
type="checkbox"
checked={pdfChecked}
onChange={onTogglePdf}
disabled={pdfDisabled}
className="rounded"
/>
<span>
{pdfLabel}
{pdfDisabled && <span className="ml-1">(keine PDF hinterlegt)</span>}
</span>
</label>
</div>
</div>
);
}
function SectionRow({ title, checked, onToggle, preview, extra }: SectionRowProps) {
return (
<div className="border border-gray-200 rounded-lg p-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="mt-1 rounded"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700">{title}</div>
<pre className="text-xs text-gray-500 mt-1 whitespace-pre-wrap font-sans">
{preview}
</pre>
</div>
</label>
{extra}
</div>
);
}
// ==================== Text-Block-Formatierung ====================
function fullName(
customer: { salutation?: string; firstName: string; lastName: string; companyName?: string },
contractType: string,
): string {
if (contractType === 'BUSINESS' && customer.companyName) {
return customer.companyName;
}
const parts: string[] = [];
if (customer.salutation) parts.push(customer.salutation);
parts.push(customer.firstName);
parts.push(customer.lastName);
return parts.filter(Boolean).join(' ');
}
// User-Wunsch 2026-06-21: das Modal ist für Mails AN den Anbieter gedacht.
// Die interne CRM-Kundennummer / -Vertragsnummer interessiert dort
// niemanden relevant ist nur, was der Anbieter selbst vergeben hat
// (`customerNumberAtProvider`, `contractNumberAtProvider`). Wir blenden
// die internen Nummern komplett aus.
function formatCustomerBlock(
customer: NonNullable<Contract['customer']>,
contract: Contract,
email: string,
): string {
const lines: string[] = ['Kundendaten:'];
lines.push(fullName(customer, contract.type));
if (contract.customerNumberAtProvider) {
lines.push(`Kundennummer beim Anbieter: ${contract.customerNumberAtProvider}`);
}
if (customer.birthDate) lines.push(`Geburtsdatum: ${formatDate(customer.birthDate)}`);
if (email) lines.push(`E-Mail: ${email}`);
if (customer.phone) lines.push(`Telefon: ${customer.phone}`);
if (customer.mobile) lines.push(`Mobil: ${customer.mobile}`);
return lines.join('\n');
}
function previewCustomer(customer: NonNullable<Contract['customer']>, contract: Contract): string {
return [
fullName(customer, contract.type),
contract.customerNumberAtProvider
? `Anbieter-Kdnr.: ${contract.customerNumberAtProvider}`
: '',
]
.filter(Boolean)
.join(' · ');
}
function formatAddressBlock(label: string, addr: Address): string {
const lines: string[] = [`${label}:`];
lines.push(`${addr.street} ${addr.houseNumber}`);
lines.push(`${addr.postalCode} ${addr.city}`);
if (addr.country && addr.country.toLowerCase() !== 'deutschland' && addr.country !== 'DE') {
lines.push(addr.country);
}
return lines.join('\n');
}
function previewAddress(addr: Address): string {
return `${addr.street} ${addr.houseNumber}, ${addr.postalCode} ${addr.city}`;
}
// Interne `contractNumber` raus (User-Wunsch 2026-06-21): für eine Mail
// an den Provider zählt nur die Vertragsnummer, die der Provider selbst
// vergeben hat. Vertriebsplattform-Nummern bleiben drin die nutzt der
// CRM-Mitarbeiter teilweise auch für die Plattform-Korrespondenz.
function formatContractBlock(c: Contract): string {
const lines: string[] = ['Vertragsdaten:'];
if (c.provider?.name) lines.push(`Anbieter: ${c.provider.name}`);
if (c.tariff?.name) lines.push(`Tarif: ${c.tariff.name}`);
if (c.customerNumberAtProvider) lines.push(`Kundennummer beim Anbieter: ${c.customerNumberAtProvider}`);
if (c.contractNumberAtProvider) lines.push(`Vertragsnummer beim Anbieter: ${c.contractNumberAtProvider}`);
if (c.orderNumberAtSalesPlatform) lines.push(`Auftragsnummer Vertriebsplattform: ${c.orderNumberAtSalesPlatform}`);
if (c.customerNumberAtSalesPlatform) lines.push(`Kundennummer Vertriebsplattform: ${c.customerNumberAtSalesPlatform}`);
if (c.contractNumberAtSalesPlatform) lines.push(`Vertragsnummer Vertriebsplattform: ${c.contractNumberAtSalesPlatform}`);
if (c.startDate) lines.push(`Vertragsbeginn: ${formatDate(c.startDate)}`);
if (c.endDate) lines.push(`Vertragsende: ${formatDate(c.endDate)}`);
return lines.join('\n');
}
function previewContract(c: Contract): string {
const parts: string[] = [];
if (c.contractNumberAtProvider) {
parts.push(`Anbieter-Vtr.: ${c.contractNumberAtProvider}`);
} else if (c.provider?.name) {
parts.push('(keine Anbieter-Vertragsnummer hinterlegt)');
}
if (c.provider?.name) parts.push(c.provider.name);
if (c.tariff?.name) parts.push(c.tariff.name);
return parts.join(' · ');
}
// User-Wunsch 2026-06-21: nur die letzten 4 IBAN-Stellen einfügen, nicht
// die komplette IBAN/BIC/Bank-Liste. Vollständige Kontonummern per Mail
// versenden ist sowieso heikel der Empfänger kann sich mit den letzten
// 4 Stellen für Identifikationszwecke ausweisen, ohne dass die ganze
// IBAN im Mail-Verlauf hängenbleibt.
function lastFourIban(iban: string | undefined | null): string {
if (!iban) return '';
return iban.replace(/\s+/g, '').slice(-4);
}
function formatBankBlock(b: BankCard): string {
const last4 = lastFourIban(b.iban);
if (!last4) return '';
return `Bankverbindung:\nIBAN endet auf: ${last4}`;
}
function previewBank(b: BankCard): string {
const last4 = lastFourIban(b.iban);
return last4 ? `IBAN …${last4}` : 'IBAN nicht hinterlegt';
}
function identityTypeLabel(type: IdentityDocument['type']): string {
switch (type) {
case 'PASSPORT': return 'Reisepass';
case 'DRIVERS_LICENSE': return 'Führerschein';
case 'OTHER': return 'Ausweisdokument';
case 'ID_CARD':
default: return 'Personalausweis';
}
}
// User-Wunsch 2026-06-21: nur die Ausweisnummer einfügen, keine
// Behörde / Daten wenn der Empfänger mehr Details braucht, soll er
// die beigefügte PDF benutzen.
function formatIdentityBlock(d: IdentityDocument): string {
if (!d.documentNumber) return '';
return `${identityTypeLabel(d.type)}-Nummer: ${d.documentNumber}`;
}
function previewIdentity(d: IdentityDocument): string {
return d.documentNumber ? `Nr. ${d.documentNumber}` : 'Keine Nummer hinterlegt';
}
@@ -0,0 +1,86 @@
// Hilfs-Funktionen für ComposeEmailModal und die zwei neuen Modals
// (Vertragsdokumente anhängen, Kundendaten einfügen).
import { fileUrl } from '../../utils/fileUrl';
import type { EmailAttachment } from '../../services/api';
/**
* Holt eine Server-Datei (per fileUrl mit Token) und gibt sie als
* EmailAttachment zurück. Wird sowohl für ContractDocuments als auch
* für BankCard- und IdentityDocument-PDFs benutzt.
*
* Wirft mit aussagekräftiger Message, wenn der Download fehlschlägt
* der Caller fängt das ab und zeigt einen Toast.
*/
export async function serverFileToAttachment(
documentPath: string,
filename: string,
): Promise<EmailAttachment> {
const url = fileUrl(documentPath);
if (!url) throw new Error(`Datei "${filename}" hat keinen Pfad.`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Download von "${filename}" fehlgeschlagen (HTTP ${response.status}).`,
);
}
const blob = await response.blob();
const base64 = await blobToBase64(blob);
return {
filename,
content: base64,
contentType: blob.type || 'application/octet-stream',
};
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// data:application/pdf;base64,XYZ → XYZ
const result = reader.result as string;
const base64 = result.split(',')[1] ?? '';
resolve(base64);
};
reader.onerror = () => reject(reader.error || new Error('FileReader-Fehler'));
reader.readAsDataURL(blob);
});
}
/**
* Gesamtgröße aller Anhänge berechnen (in Bytes, näherungsweise).
* Base64 ist ~33% größer als die Original-Bytes.
*/
export function totalAttachmentBytes(attachments: EmailAttachment[]): number {
return attachments.reduce(
(sum, att) => sum + Math.ceil(att.content.length * 0.75),
0,
);
}
/**
* Filename-Vorschlag für eine Bankkarte mit IBAN-Suffix damit beim
* Empfänger klar ist, welches Konto gemeint ist.
*/
export function bankCardAttachmentName(iban: string | undefined): string {
if (!iban) return 'Bankkarte.pdf';
const lastFour = iban.replace(/\s+/g, '').slice(-4);
return `Bankkarte-${lastFour}.pdf`;
}
/**
* Filename-Vorschlag für Ausweis-PDF abhängig vom Typ.
*/
export function identityDocAttachmentName(
type: string,
documentNumber: string | undefined,
): string {
const base = type === 'PASSPORT'
? 'Reisepass'
: type === 'DRIVERS_LICENSE'
? 'Fuehrerschein'
: type === 'OTHER'
? 'Ausweisdokument'
: 'Personalausweis';
return documentNumber ? `${base}-${documentNumber}.pdf` : `${base}.pdf`;
}
+49 -2
View File
@@ -1,6 +1,7 @@
import { useRef, useState } from 'react';
import { Upload } from 'lucide-react';
import { Upload, Images } from 'lucide-react';
import Button from './Button';
import JpgToPdfModal from './JpgToPdfModal';
interface FileUploadProps {
onUpload: (file: File) => Promise<void>;
@@ -8,6 +9,10 @@ interface FileUploadProps {
accept?: string;
label?: string;
disabled?: boolean;
/** Standard: aktiv, sobald `accept` PDF einschließt. Explizit auf false setzen, um den Button auszublenden. */
enableJpgToPdf?: boolean;
/** Default-Name (ohne .pdf) für die aus JPGs erzeugte PDF. */
jpgToPdfFileNameHint?: string;
}
export default function FileUpload({
@@ -16,10 +21,16 @@ export default function FileUpload({
accept = '.pdf,.jpg,.jpeg,.png',
label = 'Dokument hochladen',
disabled = false,
enableJpgToPdf,
jpgToPdfFileNameHint,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
const acceptsPdf = /pdf/i.test(accept);
const showJpgButton = (enableJpgToPdf ?? acceptsPdf) && !disabled;
const handleFileSelect = async (file: File) => {
if (!file) return;
@@ -64,6 +75,7 @@ export default function FileUpload({
<div className="space-y-2">
{existingFile ? (
!disabled && (
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
@@ -72,8 +84,21 @@ export default function FileUpload({
>
{isUploading ? 'Wird hochgeladen...' : 'Ersetzen'}
</Button>
{showJpgButton && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsJpgModalOpen(true)}
disabled={isUploading}
title="Mehrere JPGs zu einer PDF kombinieren"
>
<Images className="w-4 h-4 mr-1" /> JPGs PDF
</Button>
)}
</div>
)
) : (
<>
<div
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
dragOver
@@ -94,10 +119,23 @@ export default function FileUpload({
<>
<Upload className="w-6 h-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600">{label}</p>
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 10MB)</p>
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 25 MB)</p>
</>
)}
</div>
{showJpgButton && !isUploading && (
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setIsJpgModalOpen(true)}
title="Mehrere JPGs zu einer PDF kombinieren"
>
<Images className="w-4 h-4 mr-1" /> JPGs PDF
</Button>
</div>
)}
</>
)}
<input
@@ -108,6 +146,15 @@ export default function FileUpload({
className="hidden"
disabled={disabled || isUploading}
/>
<JpgToPdfModal
isOpen={isJpgModalOpen}
onClose={() => setIsJpgModalOpen(false)}
onComplete={(file) => {
handleFileSelect(file);
}}
fileNameHint={jpgToPdfFileNameHint}
/>
</div>
);
}
@@ -0,0 +1,515 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { jsPDF } from 'jspdf';
import {
Upload,
RotateCcw,
RotateCw,
FlipHorizontal,
FlipVertical,
Trash2,
FileText,
Repeat,
} from 'lucide-react';
import Modal from './Modal';
import Button from './Button';
interface ImageItem {
id: string;
dataUrl: string;
naturalWidth: number;
naturalHeight: number;
rotation: 0 | 90 | 180 | 270;
flipH: boolean;
flipV: boolean;
fileName: string;
}
interface JpgToPdfModalProps {
isOpen: boolean;
onClose: () => void;
onComplete: (pdfFile: File) => void;
fileNameHint?: string;
}
// Pentest 68.2 (INFO): Self-DoS-Schutz Modal kann sonst den Tab des
// Uploaders selbst zum Absturz bringen. Werte konservativ gewählt:
// 50 Bilder × 25 MB = 1.25 GB ist mehr als jede legitime Vollmacht.
const MAX_IMAGES = 50;
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
// Smartphone-Fotos haben oft 4000-6000 px Kante. Bei JPEG-Quality
// 0.95 sind das 5-10 MB pro Seite, zwei Bilder = >10 MB PDF.
// 2400 px lange Kante entspricht ~290 DPI auf A4 (Druckqualität) und
// reduziert die Pixelmenge auf 25-36 % vom Original → PDF wird
// drastisch kleiner, sichtbarer Unterschied praktisch null.
const MAX_DIMENSION = 2400;
const EMBED_QUALITY = 0.92;
function makeId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Bild-Decode fehlgeschlagen'));
img.src = dataUrl;
});
}
function downscaleIfNeeded(image: HTMLImageElement): HTMLCanvasElement | null {
const w = image.naturalWidth;
const h = image.naturalHeight;
if (w <= MAX_DIMENSION && h <= MAX_DIMENSION) return null;
const scale = MAX_DIMENSION / Math.max(w, h);
const newW = Math.round(w * scale);
const newH = Math.round(h * scale);
const canvas = document.createElement('canvas');
canvas.width = newW;
canvas.height = newH;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, newW, newH);
return canvas;
}
function renderImageToCanvas(image: HTMLImageElement, item: ImageItem): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas-Kontext konnte nicht erstellt werden');
const w = image.naturalWidth;
const h = image.naturalHeight;
const rotated = item.rotation === 90 || item.rotation === 270;
canvas.width = rotated ? h : w;
canvas.height = rotated ? w : h;
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((item.rotation * Math.PI) / 180);
ctx.scale(item.flipH ? -1 : 1, item.flipV ? -1 : 1);
ctx.drawImage(image, -w / 2, -h / 2);
ctx.restore();
return canvas;
}
export default function JpgToPdfModal({
isOpen,
onClose,
onComplete,
fileNameHint,
}: JpgToPdfModalProps) {
const [images, setImages] = useState<ImageItem[]>([]);
const [dragSrcIdx, setDragSrcIdx] = useState<number | null>(null);
const [isOverModal, setIsOverModal] = useState(false);
const [isBuilding, setIsBuilding] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!isOpen) {
setImages([]);
setDragSrcIdx(null);
setIsOverModal(false);
setIsBuilding(false);
setError(null);
}
}, [isOpen]);
const addFiles = useCallback(async (files: FileList | File[]) => {
const list = Array.from(files).filter((f) => f.type.startsWith('image/'));
if (list.length === 0) {
setError('Nur Bilddateien erlaubt (JPG/PNG).');
return;
}
setError(null);
const added: ImageItem[] = [];
for (const file of list) {
// 68.2: Self-DoS-Schutz harte Schranken pro Bild und gesamt.
if (file.size > MAX_IMAGE_BYTES) {
setError(`Bild zu groß (max. ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)} MB): ${file.name || 'unbenannt'}`);
continue;
}
if (images.length + added.length >= MAX_IMAGES) {
setError(`Maximal ${MAX_IMAGES} Bilder pro PDF erlaubt.`);
break;
}
try {
const rawDataUrl = await readFileAsDataUrl(file);
const img = await loadImage(rawDataUrl);
// Beim Hinzufügen direkt auf MAX_DIMENSION runterskalieren, damit
// die Vorschau, das Rendern in der PDF und die finale Dateigröße
// alle auf vernünftigen Pixelmaßen arbeiten.
const downscaled = downscaleIfNeeded(img);
const dataUrl = downscaled
? downscaled.toDataURL('image/jpeg', EMBED_QUALITY)
: rawDataUrl;
const finalW = downscaled ? downscaled.width : img.naturalWidth;
const finalH = downscaled ? downscaled.height : img.naturalHeight;
added.push({
id: makeId(),
dataUrl,
naturalWidth: finalW,
naturalHeight: finalH,
rotation: 0,
flipH: false,
flipV: false,
fileName: file.name || 'clipboard.png',
});
} catch {
setError(`Bild konnte nicht geladen werden: ${file.name || 'unbenannt'}`);
}
}
if (added.length > 0) {
setImages((prev) => [...prev, ...added]);
}
}, [images.length]);
useEffect(() => {
if (!isOpen) return;
const handler = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) files.push(f);
}
}
if (files.length > 0) {
e.preventDefault();
addFiles(files);
}
};
document.addEventListener('paste', handler);
return () => document.removeEventListener('paste', handler);
}, [isOpen, addFiles]);
const rotate = (id: string, delta: 90 | -90 | 180) => {
setImages((prev) =>
prev.map((it) =>
it.id === id
? {
...it,
rotation: ((((it.rotation + delta) % 360) + 360) % 360) as ImageItem['rotation'],
}
: it
)
);
};
const flip = (id: string, axis: 'h' | 'v') => {
setImages((prev) =>
prev.map((it) =>
it.id === id
? { ...it, ...(axis === 'h' ? { flipH: !it.flipH } : { flipV: !it.flipV }) }
: it
)
);
};
const remove = (id: string) => {
setImages((prev) => prev.filter((it) => it.id !== id));
};
const handleDragStart = (idx: number) => (e: React.DragEvent) => {
setDragSrcIdx(idx);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
};
const handleItemDragOver = (e: React.DragEvent) => {
if (dragSrcIdx === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDropOnItem = (idx: number) => (e: React.DragEvent) => {
if (dragSrcIdx === null) return;
e.preventDefault();
e.stopPropagation();
if (dragSrcIdx === idx) {
setDragSrcIdx(null);
return;
}
setImages((prev) => {
const next = [...prev];
const [moved] = next.splice(dragSrcIdx, 1);
next.splice(idx, 0, moved);
return next;
});
setDragSrcIdx(null);
};
const handleModalDragOver = (e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
setIsOverModal(true);
}
};
const handleModalDragLeave = (e: React.DragEvent) => {
if (e.target === e.currentTarget) setIsOverModal(false);
};
const handleModalDrop = (e: React.DragEvent) => {
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
setIsOverModal(false);
addFiles(e.dataTransfer.files);
}
};
const buildPdf = async () => {
if (images.length === 0) return;
setIsBuilding(true);
setError(null);
try {
const a4w = 210;
const a4h = 297;
let pdf: jsPDF | null = null;
for (let i = 0; i < images.length; i++) {
const item = images[i];
const untouched = item.rotation === 0 && !item.flipH && !item.flipV;
// Fix 1: ungedrehte/ungespiegelte Bilder bekommen ihre Original-Bytes
// direkt in die PDF eingebettet kein Canvas-Re-Encode, kein
// Quality-Aufblasen. Ein 2-MB-JPEG bleibt 2 MB statt 8-15 MB zu werden.
// Fix 2: wenn doch transformiert wird (Rotation/Flip), Canvas mit
// Quality 0.95 statt 1.0 visuell identisch für Foto-Inhalte, aber
// 50-70 % kleiner.
let imageData: string;
let imageFormat: 'JPEG' | 'PNG';
let srcW: number;
let srcH: number;
if (untouched) {
imageData = item.dataUrl;
imageFormat = item.dataUrl.startsWith('data:image/png') ? 'PNG' : 'JPEG';
srcW = item.naturalWidth;
srcH = item.naturalHeight;
} else {
const img = await loadImage(item.dataUrl);
const canvas = renderImageToCanvas(img, item);
imageData = canvas.toDataURL('image/jpeg', EMBED_QUALITY);
imageFormat = 'JPEG';
srcW = canvas.width;
srcH = canvas.height;
}
const orientation: 'portrait' | 'landscape' =
srcW > srcH ? 'landscape' : 'portrait';
const pageW = orientation === 'landscape' ? a4h : a4w;
const pageH = orientation === 'landscape' ? a4w : a4h;
if (!pdf) {
pdf = new jsPDF({ orientation, unit: 'mm', format: 'a4' });
} else {
pdf.addPage('a4', orientation);
}
const margin = 5;
const maxW = pageW - 2 * margin;
const maxH = pageH - 2 * margin;
const ratio = Math.min(maxW / srcW, maxH / srcH);
const w = srcW * ratio;
const h = srcH * ratio;
const x = (pageW - w) / 2;
const y = (pageH - h) / 2;
pdf.addImage(imageData, imageFormat, x, y, w, h, undefined, 'SLOW');
}
const blob = pdf!.output('blob');
const base = (fileNameHint || 'bilder').replace(/[^\w.-]+/g, '_').slice(0, 80) || 'bilder';
const file = new File([blob], `${base}.pdf`, { type: 'application/pdf' });
onComplete(file);
onClose();
} catch (e) {
console.error('PDF-Erstellung fehlgeschlagen:', e);
setError('PDF konnte nicht erstellt werden.');
} finally {
setIsBuilding(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="JPGs zu PDF" size="xl">
<div
onDragOver={handleModalDragOver}
onDragLeave={handleModalDragLeave}
onDrop={handleModalDrop}
className={`space-y-4 ${isOverModal ? 'ring-2 ring-blue-400 rounded-lg' : ''}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-gray-600">
Bilder per Klick wählen, hier reinziehen, oder mit{' '}
<kbd className="px-1.5 py-0.5 border rounded text-xs">Strg</kbd>+
<kbd className="px-1.5 py-0.5 border rounded text-xs">V</kbd> aus der Zwischenablage
einfügen.
</p>
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isBuilding}
>
<Upload className="w-4 h-4 mr-1" /> Bilder hinzufügen
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
e.target.value = '';
}}
/>
</div>
{error && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
{error}
</div>
)}
{images.length === 0 ? (
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed rounded-lg p-12 text-center cursor-pointer border-gray-300 hover:border-gray-400 hover:bg-gray-50"
>
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600">
Bilder hier hineinziehen oder zum Auswählen klicken
</p>
<p className="text-xs text-gray-400 mt-1">
JPG / PNG · Reihenfolge per Drag &amp; Drop, einzeln drehen/spiegeln
</p>
</div>
) : (
<>
<div className="text-xs text-gray-500">
{images.length} {images.length === 1 ? 'Bild' : 'Bilder'} · Reihenfolge per
Drag&amp;Drop, einzeln drehen/spiegeln. Jedes Bild = eine Seite.
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 max-h-[60vh] overflow-y-auto p-1">
{images.map((item, idx) => {
const transforms = [
`rotate(${item.rotation}deg)`,
`scaleX(${item.flipH ? -1 : 1})`,
`scaleY(${item.flipV ? -1 : 1})`,
].join(' ');
return (
<div
key={item.id}
draggable
onDragStart={handleDragStart(idx)}
onDragOver={handleItemDragOver}
onDrop={handleDropOnItem(idx)}
className={`border rounded-lg p-2 bg-white shadow-sm cursor-move ${
dragSrcIdx === idx ? 'opacity-40' : ''
}`}
title="Zum Sortieren ziehen"
>
<div className="flex items-center justify-between mb-1.5 text-xs">
<span className="font-semibold text-gray-700">#{idx + 1}</span>
<button
type="button"
onClick={() => remove(item.id)}
className="text-red-500 hover:text-red-700"
title="Entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="aspect-square bg-gray-100 rounded flex items-center justify-center overflow-hidden">
<img
src={item.dataUrl}
alt={item.fileName}
style={{ transform: transforms }}
className="max-w-full max-h-full object-contain transition-transform"
draggable={false}
/>
</div>
<div className="mt-1.5 flex justify-center gap-0.5">
<button
type="button"
onClick={() => rotate(item.id, -90)}
className="p-1 hover:bg-gray-100 rounded"
title="90° gegen Uhrzeigersinn"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => rotate(item.id, 90)}
className="p-1 hover:bg-gray-100 rounded"
title="90° im Uhrzeigersinn"
>
<RotateCw className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => rotate(item.id, 180)}
className="p-1 hover:bg-gray-100 rounded"
title="180° drehen"
>
<Repeat className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => flip(item.id, 'h')}
className={`p-1 rounded ${
item.flipH ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
}`}
title="Horizontal spiegeln"
>
<FlipHorizontal className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => flip(item.id, 'v')}
className={`p-1 rounded ${
item.flipV ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
}`}
title="Vertikal spiegeln"
>
<FlipVertical className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</>
)}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="ghost" onClick={onClose} disabled={isBuilding}>
Abbrechen
</Button>
<Button onClick={buildPdf} disabled={images.length === 0 || isBuilding}>
<FileText className="w-4 h-4 mr-1" />
{isBuilding ? 'Erstelle PDF...' : 'PDF erstellen & hochladen'}
</Button>
</div>
</div>
</Modal>
);
}
+30 -3
View File
@@ -1,4 +1,5 @@
import { ReactNode, useState, useEffect } from 'react';
import { ExternalLink } from 'lucide-react';
interface Tab {
id: string;
@@ -11,9 +12,21 @@ interface TabsProps {
defaultTab?: string;
activeTab?: string;
onTabChange?: (tabId: string) => void;
/**
* Optional: liefert die URL, unter der ein einzelner Tab in einem
* neuen Tab geöffnet werden kann. Wenn gesetzt, erscheint neben jedem
* Tab-Label ein kleines im neuen Tab öffnen"-Icon.
*/
tabHrefBuilder?: (tabId: string) => string;
}
export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTabChange }: TabsProps) {
export default function Tabs({
tabs,
defaultTab,
activeTab: controlledTab,
onTabChange,
tabHrefBuilder,
}: TabsProps) {
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
const activeTab = controlledTab ?? internalTab;
@@ -31,10 +44,10 @@ export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTab
return (
<div>
<div className="border-b border-gray-200">
<nav className="flex -mb-px space-x-8">
<nav className="flex -mb-px space-x-6">
{tabs.map((tab) => (
<div key={tab.id} className="flex items-center gap-1">
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
activeTab === tab.id
@@ -44,6 +57,20 @@ export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTab
>
{tab.label}
</button>
{tabHrefBuilder && (
<a
href={tabHrefBuilder(tab.id)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-gray-400 hover:text-blue-600 p-0.5"
title={`${tab.label} in neuem Tab öffnen`}
aria-label={`${tab.label} in neuem Tab öffnen`}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
))}
</nav>
</div>
@@ -14,7 +14,8 @@ import Badge from '../../components/ui/Badge';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText } from 'lucide-react';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText, Images } from 'lucide-react';
import JpgToPdfModal from '../../components/ui/JpgToPdfModal';
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat';
@@ -137,6 +138,7 @@ function SimCardDisplay({ simCard }: { simCard: SimCard }) {
<div className="p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center gap-2 mb-2 flex-wrap">
{simCard.isMain && <Badge variant="success">Hauptkarte</Badge>}
{simCard.isEsim && <Badge variant="info">eSIM</Badge>}
{simCard.isMultisim && <Badge variant="warning">Multisim</Badge>}
{simCard.cardUser && (
<span className="text-sm text-gray-700">
@@ -1836,6 +1838,16 @@ export default function ContractDetail() {
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
</Link>
<a
href={`/customers/${c.customer.id}`}
target="_blank"
rel="noopener noreferrer"
title="Kundenakte in neuem Tab öffnen"
aria-label="Kundenakte in neuem Tab öffnen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
type="button"
onClick={() => setShowCustomerInfo(true)}
@@ -2078,6 +2090,33 @@ export default function ContractDetail() {
</dd>
</div>
)}
{c.orderNumberAtSalesPlatform && (
<div>
<dt className="text-sm text-gray-500">Auftragsnr. Vertriebsplattform</dt>
<dd className="font-mono flex items-center gap-1">
{c.orderNumberAtSalesPlatform}
<CopyButton value={c.orderNumberAtSalesPlatform} />
</dd>
</div>
)}
{c.customerNumberAtSalesPlatform && (
<div>
<dt className="text-sm text-gray-500">Kundennr. Vertriebsplattform</dt>
<dd className="font-mono flex items-center gap-1">
{c.customerNumberAtSalesPlatform}
<CopyButton value={c.customerNumberAtSalesPlatform} />
</dd>
</div>
)}
{c.contractNumberAtSalesPlatform && (
<div>
<dt className="text-sm text-gray-500">Vertragsnr. Vertriebsplattform</dt>
<dd className="font-mono flex items-center gap-1">
{c.contractNumberAtSalesPlatform}
<CopyButton value={c.contractNumberAtSalesPlatform} />
</dd>
</div>
)}
{c.salesPlatform && (
<div>
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
@@ -3484,6 +3523,7 @@ function ContractDocumentsSection({
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
() => new Date().toISOString().split('T')[0],
);
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
const { data: docsData } = useQuery({
queryKey: ['contract-documents', contractId],
@@ -3581,15 +3621,31 @@ function ContractDocumentsSection({
</p>
</div>
)}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
<label className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer text-sm">
<Plus className="w-4 h-4" />
Datei wählen (PDF, JPG, PNG)
<input type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={handleFileSelect} />
</label>
<Button variant="ghost" size="sm" onClick={() => setIsJpgModalOpen(true)} title="Mehrere JPGs zu einer PDF kombinieren">
<Images className="w-4 h-4 mr-1" /> JPGs PDF
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowUpload(false)}>Abbrechen</Button>
{uploadMutation.isPending && <span className="text-sm text-gray-500">Hochladen...</span>}
</div>
<JpgToPdfModal
isOpen={isJpgModalOpen}
onClose={() => setIsJpgModalOpen(false)}
onComplete={(file) => {
uploadMutation.mutate({
file,
documentType: uploadType,
notes: uploadNotes || undefined,
deliveryDate: isDelivery ? uploadDeliveryDate : undefined,
});
}}
fileNameHint={uploadType}
/>
{uploadMutation.isError && (
<p className="text-xs text-red-600 mt-2">Fehler beim Hochladen</p>
)}
+90 -4
View File
@@ -9,6 +9,8 @@ import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import CopyButton from '../../components/ui/CopyButton';
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
import type { ContractType } from '../../types';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
@@ -192,6 +194,7 @@ export default function ContractForm() {
hasExistingPuk?: boolean; // Zeigt an ob PUK bereits in DB vorhanden
isMultisim: boolean;
isMain: boolean;
isEsim: boolean;
cardUser: string;
}
const [simCards, setSimCards] = useState<SimCardInput[]>([]);
@@ -219,6 +222,7 @@ export default function ContractForm() {
// Passwort-Sichtbarkeit
const [showPortalPassword, setShowPortalPassword] = useState(false);
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
const [showInternetPassword, setShowInternetPassword] = useState(false);
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
@@ -301,6 +305,9 @@ export default function ContractForm() {
tariffName: c.tariffName || '',
customerNumberAtProvider: c.customerNumberAtProvider || '',
contractNumberAtProvider: c.contractNumberAtProvider || '',
orderNumberAtSalesPlatform: c.orderNumberAtSalesPlatform || '',
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
priceFirst12Months: c.priceFirst12Months || '',
priceFrom13Months: c.priceFrom13Months || '',
priceAfter24Months: c.priceAfter24Months || '',
@@ -384,6 +391,7 @@ export default function ContractForm() {
hasExistingPuk: !!sc.puk, // true wenn verschlüsselter Wert vorhanden
isMultisim: sc.isMultisim,
isMain: sc.isMain,
isEsim: sc.isEsim ?? false,
cardUser: sc.cardUser || '',
})));
} else {
@@ -554,6 +562,9 @@ export default function ContractForm() {
tariffName: emptyToNull(data.tariffName),
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
orderNumberAtSalesPlatform: emptyToNull(data.orderNumberAtSalesPlatform),
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
priceFirst12Months: emptyToNull(data.priceFirst12Months),
priceFrom13Months: emptyToNull(data.priceFrom13Months),
priceAfter24Months: emptyToNull(data.priceAfter24Months),
@@ -650,6 +661,7 @@ export default function ContractForm() {
puk: sc.puk || undefined, // Passwort: undefined = nicht ändern
isMultisim: sc.isMultisim,
isMain: sc.isMain,
isEsim: sc.isEsim,
cardUser: emptyToNull(sc.cardUser),
})) : undefined,
};
@@ -765,7 +777,7 @@ export default function ContractForm() {
return (
<div>
<div className="flex items-center gap-4 mb-6">
<div className="flex items-center gap-4 mb-2">
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
@@ -773,6 +785,36 @@ export default function ContractForm() {
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
</h1>
</div>
{customer && (
<p className="text-gray-500 ml-12 mb-6 flex items-center gap-1">
Kunde:{' '}
<Link
to={`/customers/${customer.id}`}
className="text-blue-600 hover:underline"
>
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
</Link>
<a
href={`/customers/${customer.id}`}
target="_blank"
rel="noopener noreferrer"
title="Kundenakte in neuem Tab öffnen"
aria-label="Kundenakte in neuem Tab öffnen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
type="button"
onClick={() => setShowCustomerInfo(true)}
title="Wichtige Kundendaten anzeigen (Schnellansicht mit Copy-Buttons)"
aria-label="Kundendaten anzeigen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<Info className="w-4 h-4" />
</button>
</p>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
@@ -947,8 +989,11 @@ export default function ContractForm() {
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
disabled={!selectedProviderId}
/>
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
<Input label="Kundennummer beim Anbieter" maxLength={100} {...register('customerNumberAtProvider')} />
<Input label="Vertragsnummer beim Anbieter" maxLength={100} {...register('contractNumberAtProvider')} />
<Input label="Auftragsnummer bei Vertriebsplattform" maxLength={100} {...register('orderNumberAtSalesPlatform')} />
<Input label="Kundennummer bei Vertriebsplattform" maxLength={100} {...register('customerNumberAtSalesPlatform')} />
<Input label="Vertragsnummer bei Vertriebsplattform" maxLength={100} {...register('contractNumberAtSalesPlatform')} />
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
@@ -1005,7 +1050,24 @@ export default function ContractForm() {
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Portal Benutzername</label>
{(() => {
// Aktiv kopierbaren Wert je nach Modus ermitteln:
// - Manuell: aktueller Eingabewert von portalUsername
// - Stressfrei: E-Mail der ausgewählten Stressfrei-Adresse
const manualUsername = (watch('portalUsername') as string) || '';
const selectedStressfreiEmail = selectedStressfreiEmailId
? stressfreiEmails.find((e: { id: number; email: string }) => e.id.toString() === selectedStressfreiEmailId)?.email
: '';
const copyValue = usernameType === 'manual'
? manualUsername.trim()
: (selectedStressfreiEmail || '');
return (
<label className="flex items-center gap-2 mb-2 text-sm font-medium text-gray-700">
<span>Portal Benutzername</span>
{copyValue && <CopyButton value={copyValue} title={`Benutzername "${copyValue}" in Zwischenablage kopieren`} />}
</label>
);
})()}
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -1023,6 +1085,7 @@ export default function ContractForm() {
{usernameType === 'manual' && (
<Input
{...register('portalUsername')}
maxLength={100}
placeholder="Benutzername eingeben..."
/>
)}
@@ -1517,6 +1580,19 @@ export default function ContractForm() {
/>
Hauptkarte
</label>
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={card.isEsim}
onChange={(e) => {
const updated = [...simCards];
updated[index].isEsim = e.target.checked;
setSimCards(updated);
}}
className="rounded border-gray-300"
/>
eSIM
</label>
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
@@ -1642,6 +1718,7 @@ export default function ContractForm() {
puk: '',
isMultisim: false,
isMain: simCards.length === 0, // Erste Karte ist Hauptkarte
isEsim: false,
cardUser: '',
}]);
}}
@@ -1719,6 +1796,15 @@ export default function ContractForm() {
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
{/* Kunden-Schnellansicht */}
{customer && (
<CustomerInfoModal
customerId={customer.id}
open={showCustomerInfo}
onClose={() => setShowCustomerInfo(false)}
/>
)}
</div>
);
}
+316 -19
View File
@@ -14,7 +14,8 @@ import Modal from '../../components/ui/Modal';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake, RefreshCw, ExternalLink } from 'lucide-react';
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake, RefreshCw, ExternalLink, Images } from 'lucide-react';
import JpgToPdfModal from '../../components/ui/JpgToPdfModal';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
import { formatDate } from '../../utils/dateFormat';
@@ -410,7 +411,13 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
)}
<Card>
<Tabs tabs={tabs} defaultTab={defaultTab} activeTab={activeTab} onTabChange={handleTabChange} />
<Tabs
tabs={tabs}
defaultTab={defaultTab}
activeTab={activeTab}
onTabChange={handleTabChange}
tabHrefBuilder={(tabId) => `${location.pathname}?tab=${tabId}`}
/>
</Card>
<AddressModal
@@ -2450,10 +2457,13 @@ function AddressModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when address prop changes
if (isEditing && formData.street !== address.street) {
// Beim Öffnen / Wechsel zwischen Adressen aus den Props re-initialisieren.
// Vorher als unbedingte if-setState im Render-Body → Reset bei jedem
// Tastendruck, Straße ließ sich nicht eintippen.
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address?.id]);
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
@@ -2669,10 +2679,12 @@ function BankCardModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when bankCard prop changes
if (isEditing && formData.iban !== bankCard.iban) {
// Re-Init nur beim Wechsel zur anderen Karte nicht bei jedem
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bankCard?.id]);
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
@@ -2819,10 +2831,12 @@ function DocumentModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when document prop changes
if (isEditing && formData.documentNumber !== document.documentNumber) {
// Re-Init nur beim Wechsel zum anderen Ausweis nicht bei jedem
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [document?.id]);
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
@@ -3029,10 +3043,12 @@ function MeterModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when meter prop changes
if (isEditing && formData.meterNumber !== meter.meterNumber) {
// Re-Init nur beim Wechsel zum anderen Zähler nicht bei jedem
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meter?.id]);
const noDeliveryAddresses = deliveryAddresses.length === 0;
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
@@ -3266,10 +3282,12 @@ function MeterReadingModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when reading prop changes
if (isEditing && formData.value !== reading.value.toString()) {
// Re-Init nur beim Wechsel zum anderen Zählerstand nicht bei
// jedem Tastendruck (das löste vorher Reset auf DB-Wert aus).
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reading?.id]);
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
@@ -3726,6 +3744,8 @@ function StressfreiEmailModal({
} | null>(null);
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [showForwardsModal, setShowForwardsModal] = useState(false);
const [additionalForwards, setAdditionalForwards] = useState<string[]>([]);
const queryClient = useQueryClient();
const isEditing = !!email;
@@ -3864,6 +3884,15 @@ function StressfreiEmailModal({
setNotes(email.notes || '');
setProviderStatus('idle');
setMailboxEnabled(email.hasMailbox || false);
// Aktuelle Zusatz-Weiterleitungen aus dem JSON-Feld parsen.
let parsed: string[] = [];
if (email.additionalForwardingEmails) {
try {
const x = JSON.parse(email.additionalForwardingEmails);
if (Array.isArray(x)) parsed = x.filter((s): s is string => typeof s === 'string');
} catch {/* fällt auf [] zurück */}
}
setAdditionalForwards(parsed);
// Status beim Provider prüfen wenn Provider vorhanden
if (hasProvider) {
checkProviderStatus(emailLocalPart);
@@ -3877,6 +3906,7 @@ function StressfreiEmailModal({
setCreateMailbox(false);
setProviderStatus('idle');
setMailboxEnabled(false);
setAdditionalForwards([]);
}
setProvisionError(null);
// Zugangsdaten zurücksetzen
@@ -3888,12 +3918,25 @@ function StressfreiEmailModal({
const createMutation = useMutation({
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
// Verwendet die neue API-Funktion, die Provisioning und Mailbox-Erstellung unterstützt
return stressfreiEmailApi.create(customerId, {
const result = await stressfreiEmailApi.create(customerId, {
email: data.email,
notes: data.notes,
provisionAtProvider: data.provision,
createMailbox: data.createMailbox,
});
// Wenn der User zusätzliche Weiterleitungen im Sub-Modal gepflegt
// hat: nach der Erstellung gleich am Provider nachziehen. Das
// sorgt für einen `set:`-Sync mit der vollen Liste, kein
// Edit-Modus-Roundtrip nötig.
if (data.provision && additionalForwards.length > 0 && result.data?.id) {
try {
await stressfreiEmailApi.updateAdditionalForwards(result.data.id, additionalForwards);
} catch (e) {
console.error('Zusatz-Weiterleitungen konnten nicht gesetzt werden:', e);
// Adresse selbst wurde angelegt nicht hart fehlschlagen.
}
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
@@ -3902,6 +3945,7 @@ function StressfreiEmailModal({
setNotes('');
setProvisionAtProvider(false);
setCreateMailbox(false);
setAdditionalForwards([]);
onClose();
},
onError: (error) => {
@@ -3916,6 +3960,9 @@ function StressfreiEmailModal({
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
onError: (err) => {
setProvisionError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const handleSubmit = (e: React.FormEvent) => {
@@ -4157,7 +4204,28 @@ function StressfreiEmailModal({
</div>
)}
<div className="flex justify-end gap-2">
<div className="flex justify-between items-center gap-2">
<div>
{((isEditing && email && providerStatus === 'exists') ||
(!isEditing && provisionAtProvider)) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowForwardsModal(true)}
title="Zusätzliche Weiterleitungs-Adressen pflegen"
>
<Mail className="w-4 h-4 mr-1" />
Weitere Weiterleitungen
{additionalForwards.length > 0 && (
<span className="ml-1 text-xs bg-blue-100 text-blue-700 rounded-full px-1.5">
{additionalForwards.length}
</span>
)}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
@@ -4165,7 +4233,214 @@ function StressfreiEmailModal({
{isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
</form>
<AdditionalForwardsModal
isOpen={showForwardsModal}
onClose={() => setShowForwardsModal(false)}
email={email ?? undefined}
customerEmail={customerEmail}
selfEmail={localPart ? localPart + domainSuffix : undefined}
value={additionalForwards}
onChange={setAdditionalForwards}
/>
</Modal>
);
}
// Untermodal: zusätzliche Weiterleitungs-E-Mails verwalten.
// Edit-Modus: `email` prop gesetzt → jede Änderung wird sofort am
// Provider gesynct.
// Create-Modus: `email` undefined → reine lokale Verwaltung über
// `value`/`onChange`. Wird beim createEmail-Submit mitgegeben.
function AdditionalForwardsModal({
isOpen,
onClose,
email,
customerEmail,
selfEmail,
value,
onChange,
}: {
isOpen: boolean;
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;
}) {
const queryClient = useQueryClient();
const [newEmail, setNewEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
setNewEmail('');
setError(null);
}, [isOpen]);
const EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
const persist = async (next: string[]) => {
setError(null);
if (email) {
// Edit-Modus: sofort am Provider syncen.
setIsSubmitting(true);
try {
await stressfreiEmailApi.updateAdditionalForwards(email.id, next);
onChange(next);
queryClient.invalidateQueries({ queryKey: ['stressfrei-emails', email.customerId] });
} catch (e) {
const msg = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
setError(msg);
throw e;
} finally {
setIsSubmitting(false);
}
} else {
// Create-Modus: nur lokal updaten. Persistierung beim Submit
// des Haupt-Modals.
onChange(next);
}
};
// 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();
if (!candidate) return;
if (!EMAIL_REGEX.test(candidate)) {
setError('Bitte eine gültige E-Mail-Adresse eingeben.');
return;
}
const candidateKey = canonicalize(candidate);
if (customerEmail && candidateKey === canonicalize(customerEmail)) {
setError('Die Stamm-E-Mail des Kunden ist bereits Weiterleitungsziel.');
return;
}
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;
}
try {
await persist([...value, candidate]);
setNewEmail('');
} catch {
/* error wird oben gesetzt */
}
};
const handleRemove = async (target: string) => {
try {
await persist(value.filter((f) => f !== target));
} catch {
/* error wird oben gesetzt */
}
};
const title = email
? `Weiterleitungen für ${email.email}`
: 'Weitere Weiterleitungen';
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Posteingänge gehen immer an die Stamm-E-Mail des Kunden
{customerEmail && (
<> (<span className="font-mono">{customerEmail}</span>)</>
)}
. Hier kannst du zusätzliche Adressen hinterlegen, die ebenfalls eine Kopie bekommen.
{email
? ' Änderungen werden sofort am E-Mail-Provider übernommen.'
: ' Sie werden zusammen mit der Adresse angelegt, sobald du speicherst.'}
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Aktuelle zusätzliche Ziele
</label>
{value.length === 0 ? (
<p className="text-sm text-gray-500 italic">Noch keine zusätzlichen Adressen.</p>
) : (
<ul className="space-y-1">
{value.map((f) => (
<li
key={f}
className="flex items-center justify-between bg-gray-50 border border-gray-200 rounded px-3 py-2"
>
<span className="font-mono text-sm">{f}</span>
<button
type="button"
onClick={() => handleRemove(f)}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
disabled={isSubmitting}
title="Entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
<form onSubmit={handleAdd} className="border-t pt-3 space-y-2">
<label className="block text-sm font-medium text-gray-700">
Weitere Adresse hinzufügen
</label>
<div className="flex gap-2">
<input
type="email"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
setError(null);
}}
placeholder="z.B. info@partner.de"
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isSubmitting}
maxLength={254}
/>
<Button type="submit" disabled={isSubmitting || !newEmail.trim()}>
<Plus className="w-4 h-4 mr-1" />
Hinzufügen
</Button>
</div>
</form>
{error && (
<div className="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end">
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
Schließen
</Button>
</div>
</div>
</Modal>
);
}
@@ -4560,6 +4835,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
const queryClient = useQueryClient();
const { user } = useAuth();
const [sendDropdownFor, setSendDropdownFor] = useState<number | null>(null);
const [jpgModalFor, setJpgModalFor] = useState<number | null>(null);
const { data: authData, isLoading } = useQuery({
queryKey: ['authorizations', customerId],
@@ -4730,6 +5006,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
</button>
</>
) : (
<>
<label className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1">
<Plus className="w-3 h-3" />
Vollmacht-PDF hochladen
@@ -4740,11 +5017,31 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
onChange={(e) => handleFileUpload(auth.representativeId, e)}
/>
</label>
<button
type="button"
onClick={() => setJpgModalFor(auth.representativeId)}
className="text-xs text-blue-600 hover:underline cursor-pointer flex items-center gap-1"
title="Mehrere JPGs zu einer PDF kombinieren"
>
<Images className="w-3 h-3" />
JPGs PDF
</button>
</>
)}
</div>
</div>
))}
</div>
<JpgToPdfModal
isOpen={jpgModalFor !== null}
onClose={() => setJpgModalFor(null)}
onComplete={(file) => {
if (jpgModalFor !== null) {
uploadMutation.mutate({ representativeId: jpgModalFor, file });
}
}}
fileNameHint="vollmacht"
/>
</div>
);
}
@@ -292,6 +292,13 @@ function ProviderModal({
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
contactEmail: '',
contactPhone: '',
contactFax: '',
contactAddress: '',
cancellationEmail: '',
cancellationFax: '',
cancellationAddress: '',
isActive: true,
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
// Admin sein eigenes Passwort mitsenden Schutz gegen kompromittierten
@@ -316,6 +323,13 @@ function ProviderModal({
portalUrl: provider.portalUrl || '',
usernameFieldName: provider.usernameFieldName || '',
passwordFieldName: provider.passwordFieldName || '',
contactEmail: provider.contactEmail || '',
contactPhone: provider.contactPhone || '',
contactFax: provider.contactFax || '',
contactAddress: provider.contactAddress || '',
cancellationEmail: provider.cancellationEmail || '',
cancellationFax: provider.cancellationFax || '',
cancellationAddress: provider.cancellationAddress || '',
isActive: provider.isActive,
currentPassword: '',
});
@@ -325,6 +339,13 @@ function ProviderModal({
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
contactEmail: '',
contactPhone: '',
contactFax: '',
contactAddress: '',
cancellationEmail: '',
cancellationFax: '',
cancellationAddress: '',
isActive: true,
currentPassword: '',
});
@@ -434,6 +455,75 @@ function ProviderModal({
/>
</div>
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
<p className="text-sm text-gray-600">
<strong>Kontakt & Kündigung</strong> (optional)<br />
Erreichbarkeit des Anbieters wird im CRM zum Nachschlagen
angezeigt, nicht an Portal-Kunden ausgespielt.
</p>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Kontakt</div>
<Input
label="Kontakt-Emailadresse"
type="email"
value={formData.contactEmail}
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
placeholder="z.B. service@anbieter.de"
/>
<Input
label="Kontakt-Telefonnummer"
value={formData.contactPhone}
onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
placeholder="z.B. +49 30 1234567"
/>
<Input
label="Kontakt-Faxnummer"
value={formData.contactFax}
onChange={(e) => setFormData({ ...formData, contactFax: e.target.value })}
placeholder="z.B. +49 30 7654321"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kontakt-Postadresse
</label>
<textarea
value={formData.contactAddress}
onChange={(e) => setFormData({ ...formData, contactAddress: e.target.value })}
rows={3}
maxLength={500}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Musteranbieter GmbH&#10;Musterstraße 1&#10;12345 Berlin"
/>
</div>
<div className="pt-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Kündigung</div>
<Input
label="Kündigungs-Emailadresse"
type="email"
value={formData.cancellationEmail}
onChange={(e) => setFormData({ ...formData, cancellationEmail: e.target.value })}
placeholder="z.B. kuendigung@anbieter.de"
/>
<Input
label="Kündigungs-Faxnummer"
value={formData.cancellationFax}
onChange={(e) => setFormData({ ...formData, cancellationFax: e.target.value })}
placeholder="z.B. +49 30 9876543"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kündigungs-Postadresse
</label>
<textarea
value={formData.cancellationAddress}
onChange={(e) => setFormData({ ...formData, cancellationAddress: e.target.value })}
rows={3}
maxLength={500}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Musteranbieter GmbH&#10;Abteilung Kündigung&#10;Musterstraße 1&#10;12345 Berlin"
/>
</div>
</div>
{provider && (
<label className="flex items-center gap-2">
<input
+26 -11
View File
@@ -369,6 +369,8 @@ export interface StressfreiEmail {
isActive: boolean;
isProvisioned?: boolean;
hasMailbox: boolean;
/** Zusätzliche Weiterleitungs-E-Mails als JSON-Array-String. */
additionalForwardingEmails?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -537,6 +539,14 @@ export const stressfreiEmailApi = {
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
return res.data;
},
// Zusätzliche Weiterleitungs-Adressen ersetzen + sofort am Provider syncen.
updateAdditionalForwards: async (id: number, emails: string[]) => {
const res = await api.put<ApiResponse<{ forwardTargets: string[] }>>(
`/stressfrei-emails/${id}/additional-forwards`,
{ emails },
);
return res.data;
},
// E-Mails synchronisieren
syncEmails: async (id: number, fullSync = false) => {
const res = await api.post<ApiResponse<SyncResult>>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });
@@ -587,19 +597,24 @@ export const cachedEmailApi = {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
return res.data;
},
// E-Mails für Vertrag abrufen
getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
// E-Mails für Vertrag abrufen (optional pro Postfach gefiltert)
getForContract: async (
contractId: number,
options?: { folder?: 'INBOX' | 'SENT'; accountId?: number; limit?: number; offset?: number },
) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
return res.data;
},
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails)
getContractFolderCounts: async (contractId: number) => {
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails, optional pro Postfach)
getContractFolderCounts: async (contractId: number, accountId?: number) => {
const res = await api.get<ApiResponse<{
inbox: number;
inboxUnread: number;
sent: number;
sentUnread: number;
}>>(`/contracts/${contractId}/emails/folder-counts`);
trash: number;
trashUnread: number;
}>>(`/contracts/${contractId}/emails/folder-counts`, { params: accountId ? { accountId } : undefined });
return res.data;
},
// Mailbox-Konten eines Kunden abrufen
@@ -659,14 +674,14 @@ export const cachedEmailApi = {
return res.data;
},
// ==================== PAPIERKORB ====================
// Papierkorb-E-Mails für Kunden abrufen
getTrash: async (customerId: number) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`);
// Papierkorb-E-Mails für Kunden abrufen (optional pro Postfach/Vertrag gefiltert)
getTrash: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`, { params: options });
return res.data;
},
// Papierkorb-Anzahl für Kunden
getTrashCount: async (customerId: number) => {
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`);
// Papierkorb-Anzahl für Kunden (gleiche Filter wie getTrash)
getTrashCount: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`, { params: options });
return res.data;
},
// E-Mail aus Papierkorb wiederherstellen
+11
View File
@@ -396,6 +396,13 @@ export interface Provider {
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
contactEmail?: string;
contactPhone?: string;
contactFax?: string;
contactAddress?: string;
cancellationEmail?: string;
cancellationFax?: string;
cancellationAddress?: string;
isActive: boolean;
tariffs?: Tariff[];
_count?: {
@@ -454,6 +461,9 @@ export interface Contract {
tariffName?: string;
customerNumberAtProvider?: string;
contractNumberAtProvider?: string;
orderNumberAtSalesPlatform?: string;
customerNumberAtSalesPlatform?: string;
contractNumberAtSalesPlatform?: string;
priceFirst12Months?: string;
priceFrom13Months?: string;
priceAfter24Months?: string;
@@ -561,6 +571,7 @@ export interface SimCard {
puk?: string; // verschlüsselt
isMultisim: boolean;
isMain: boolean;
isEsim: boolean;
// Tatsächlicher Nutzer der Karte (z.B. Mitarbeiter bei Firmenvertrag,
// Kind bei Eltern-Vertrag) optional, kann vom Vertragsinhaber abweichen.
cardUser?: string;