Compare commits

...

150 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
duffyduck da1934aa2d Cockpit: "Ausweis fehlt" nur noch bei Mobilfunk
Bei Festnetz/Internet-Verträgen (DSL, FIBER, CABLE) verlangt der
Anbieter beim Auftrag keinen Ausweis – die Cockpit-Warnung
"Ausweis fehlt" war dort nur Rauschen. Mobile bleibt drin, weil
für SIM-Kartenausgabe echte Identitätsfeststellung Pflicht ist.

Die "Ausweis läuft ab"-Warnung bleibt unverändert: sie greift nur,
wenn ein Ausweis verknüpft ist, und ist damit für alle Vertragstypen
sinnvoll (wenn schon ein Ausweis dranhängt, will der User auch
über den Ablauf informiert werden).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:19:54 +02:00
duffyduck 0f4ffe3c32 E-Mail als PDF speichern: Tab "Vertragsdokument" ergänzt
Bisher hatte das "E-Mail als PDF speichern"-Modal nur die Tabs
"Als Dokument" + "Als Rechnung" (nur Energieverträge). Wenn die
E-Mail einem Vertrag zugeordnet ist, fehlte die Möglichkeit, sie
direkt als Vertragsdokument (Auftragsformular, Lieferbestätigung
etc.) zu hinterlegen – analog zum Anhang-Modal.

Backend: neuer Endpoint POST /api/emails/:id/save-as-contract-document
{ documentType, notes?, deliveryDate? } – generiert das Mail-PDF,
speichert es unter /uploads/contract-documents und legt einen
ContractDocument-Eintrag an. Bei documentType "Lieferbestätigung"
wird der bestehende maybeActivateOnDeliveryConfirmation-Workflow
getriggert (DRAFT → ACTIVE, startDate-Übernahme).

Frontend: SaveEmailAsPdfModal bekommt den dritten Tab parallel zu
SaveAttachmentModal. Tab erscheint, sobald die E-Mail einem Vertrag
zugeordnet ist (auch bei Nicht-Energieverträgen); Tab "Als Rechnung"
bleibt auf Energieverträge beschränkt. Dokumenttyp-Dropdown und
Notizen-Feld werden aus dem Anhang-Modal übernommen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:15:23 +02:00
duffyduck 71d3ea7a2e Pentest 51.1/51.2/51.3: IPv6 SSRF, CGNAT/Alibaba, Phone-CRLF
51.1 MEDIUM (IPv6-Ranges nicht zuverlässig geblockt):
- URL.hostname liefert IPv6 mit eckigen Klammern ("[::1]") –
  safeResolveHost strippt sie jetzt am Eingang, sonst greift
  weder net.isIP noch das Regex-Matching.
- PRIVATE_IP_PATTERNS auf Hex-Group-Boundaries gehoben:
  /^f[cd][0-9a-f]{2}:/i deckt fc00..fdff zuverlässig ab statt
  nur "f[cd]" am String-Anfang.
- Ausgeschriebene IPv6-Formen (0:0:0:0:0:0:0:1, 0:0:0:0:0:ffff:10.x)
  als eigene Patterns ergänzt; "[::1]" + "0:0:0:0:0:0:0:1" auch
  als BLOCKED_HOSTNAMES.
- fe80: zusätzlich für lange Form (/^fe80:0*:/i).

51.2 LOW (CGNAT + Alibaba Metadata):
- 100.64.0.0/10 (RFC 6598 Carrier-Grade-NAT) → BLOCKED_PATTERNS
- 100.100.100.200 (Alibaba Cloud Metadata) → BLOCKED_HOSTNAMES

51.3 LOW (CRLF in phone-Feldern):
- sanitizePhoneField in contract.service.ts: Allowlist
  /^[0-9+\-/(). ]{0,40}$/ – Whitespace bewusst auf literales
  Space, NICHT \s, weil \s sonst \r\n\t matched und den
  Header-Injection-Schutz aufhebt. Eingesetzt auf phoneNumber
  und areaCode in beiden Create-Pfaden und im Update-Pfad.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:06:40 +02:00
duffyduck c3321a2aa9 Pentest 48.1 MEDIUM + 50.1 MEDIUM: customerEmailLabel-Strip + SSRF strict
48.1 (XSS in customerEmailLabel):
- Neuer sanitizeCustomerEmailLabel-Helper (stripHtml + trim +
  60-Zeichen-Cap)
- Eingesetzt in createProviderConfig + updateProviderConfig
  (Write-Pfad) und getProviderPublicSettings (Read-Defensive)
- Damit landet kein <script>/<img onerror>/<svg onload> mehr roh
  in der DB, das Längen-Limit ist serverseitig erzwungen, und
  Alt-Daten kommen über /public-settings ebenfalls gestrippt raus.

50.1 (SSRF, unvollständige Blockliste bei test-connection):
- safeResolveHost + assertAllowedHost akzeptieren jetzt
  { strict: boolean }. strict=true → isPrivateOrBlockedHost
  (sperrt 127/8, 10/8, 172.16/12, 192.168/16, ::1, fc00::/7
  unabhängig von SSRF_BLOCK_PRIVATE_IPS).
- test-connection und test-mail-access nutzen strict=true per
  Default. Opt-out via env SSRF_ALLOW_INTERNAL_TESTING=true
  für On-Prem mit internem Plesk.
- Defense-in-Depth: assertAllowedHost wird jetzt auch VOR der
  DNS-Resolution auf den Hostname selbst angewendet, damit
  Block-Hostnames (z.B. "metadata.google.internal", "localhost")
  nicht via custom-DNS umgangen werden können.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 18:29:08 +02:00
duffyduck 61daff8df9 Rufnummern: Vorwahl als eigenes Feld – verlässliche PDF-Befüllung
Bisher steht in PhoneNumber.phoneNumber die kombinierte Nummer
("04264 836975"). Die Wechselauftrag-PDFs splittten heuristisch
auf Vorwahl/Anschluss, was bei Sonderformaten daneben ging.

Schema: PhoneNumber.areaCode String? (optional, Bestandsdaten
werden beim nächsten Edit nachgepflegt). Migration
20260601200000_phone_area_code mit IF NOT EXISTS.

ContractForm: aus "Rufnummer" werden zwei Felder – "Vorwahl" und
"Rufnummer". Beim Speichern sendet das Frontend areaCode separat
UND die kombinierte phoneNumber (für Listen/Suchen weiter
unverändert). Beim Edit-Load wird areaCode bevorzugt; falls leer,
splittet die UI heuristisch und prefillt beides – User kann
korrigieren und beim Speichern wird der saubere Wert persistiert.

PDF-Template-Service: phoneAreaCode[N] und phoneLocal[N]
verwenden jetzt primär den gespeicherten areaCode aus der DB
(verlässlich), Heuristik nur als Fallback für Altbestand. Die
Template-Variablen-Liste war bereits korrekt definiert, jetzt
ist die Datenquelle solide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 14:10:25 +02:00
duffyduck 57eb29c2a6 Pentest 49.1 LOW: Re-Auth jetzt auf JEDE portalUrl-Änderung
Bisher prüfte der Re-Auth-Trigger nur den Host – `https://1und1.de/foo`
→ `https://1und1.de/phishing/path` ging ohne currentPassword durch.
Damit konnte ein gestohlener JWT Phishing-Pfade auf trusted Domains
plazieren.

Backend (provider.controller): normalizeUrlForCompare vergleicht
jetzt die komplette URL (Trailing-Slash, Whitespace, Case),
nicht nur den Host. hostOf-Helper entfernt.

Frontend (ProviderModal): gleiche Normalisierung im UI, damit der
Bestätigungs-Banner mit der Backend-Prüfung synchron läuft.
Banner-Text leicht angepasst (nicht mehr "Domain wurde geändert"
sondern generisch "Portal-URL wurde geändert").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 13:46:56 +02:00
duffyduck 5d21574c81 Pentest 48.3 MEDIUM + 48.4 INFO: Rate-Limit + Token-Invalidierung beim Staff-Passwort-Reset
48.3 (Rate-Limit fehlt): POST /api/users/:id/password verlangt seit
47.3 die Eingabe des eigenen Admin-Passworts. Ohne Throttle könnte
ein Angreifer mit gestohlenem JWT die Re-Auth per Brute-Force
aushebeln.
- Neuer staffPasswordReAuthLimiter (5 Versuche / 10 min,
  bucket: IP + target-user-id, skipSuccessfulRequests: true)
- emit SecurityEvent RATE_LIMIT_HIT severity HIGH
- Vor authenticate gemounted, damit auch unauth-Spamming
  begrenzt wird

48.4 (Alter Token überlebt Self-Reset): Nach erfolgreichem Setzen
wird tokenInvalidatedAt des Ziel-Users auf jetzt gesetzt. Greift
besonders bei Self-Reset (Admin setzt sich selbst zurück) – ein
zuvor gestohlenes Token wird sofort ungültig, statt bis zum
natürlichen Ablauf (15 min) brauchbar zu bleiben. Die bestehende
Auth-Middleware liest tokenInvalidatedAt bereits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 13:01:44 +02:00
duffyduck 2c0166ed99 Pentest 47.1/47.2/47.3: Re-Auth bei sensiblen Operationen + Provider.name-Strip
47.3 MEDIUM (Admin-Passwort-Reset ohne Re-Auth):
POST /api/users/:id/password verlangt jetzt currentPassword im
Body. Backend prüft per bcrypt.compare gegen den Hash des
aufrufenden Admins. Frontend (UserList-Modal): zusätzliches
Passwort-Feld wird eingeblendet, sobald für einen User ein neues
Passwort gesetzt werden soll. Gestohlener JWT allein reicht damit
nicht mehr.

47.1 MEDIUM (Open Redirect / Phishing via provider.portalUrl):
Selbes Re-Auth-Pattern für Provider-Endpoints. Nur wenn die
Portal-URL-Domain WIRKLICH gewechselt wird (Host-Vergleich)
oder beim Create mit URL, ist currentPassword Pflicht. Reine
Namens-/Tarif-Edits bleiben friction-frei.
Audit-Log bekommt die Portal-URL beim Ändern explizit mitgeloggt
(Forensik bei Vorfällen). Frontend ProviderModal zeigt amber-
farbenen Bestätigungs-Banner mit Passwort-Eingabe sobald der
Host wechselt.

47.2 INFO (provider.name ohne Backend-Sanitization):
Neuer Helper stripProviderStrings in provider.service, wendet
stripHtml auf name + usernameFieldName + passwordFieldName an –
Defense-in-Depth gegen neue Renderpfade (PDF, Mail-Templates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 12:38:45 +02:00
duffyduck d0d2715baa Pentest 46.1 HIGH + Info-Konsolidierung: zentrale URL-Validierung
46.1 HIGH (Stored XSS via provider.portalUrl): PUT /api/providers/:id
nahm `javascript:alert(...)` als portalUrl ohne Validierung an, das
Portal rendert es als <a href={portalUrl}> → Klick im Kunden-Browser
löste XSS aus.

Fix: neuer zentraler Helper backend/utils/url.validateHttpUrl
- erlaubt nur http(s)-Schemas (sperrt javascript:, data:, file:,
  vbscript:, blob: usw.)
- erfordert absoluten URL mit Host
- per Default keine privaten/Loopback-Hosts (über
  isPrivateOrBlockedHost), weil der Wert Endkunden gezeigt wird
- Trailing-Slash wird gestrippt

Eingebaut in:
- provider.service createProvider + updateProvider (HIGH-Fix)
- appSetting.service validateSettingValue für portalLoginUrl
  (Refactor der bestehenden ad-hoc Validierung → konsolidiert)

Defense-in-depth Frontend: frontend/utils/url.safeHttpUrl liefert
URLs nur zurück wenn http(s), sonst undefined. Eingesetzt in
ContractDetail bei Portal-Link-Rendering und Auto-Login, damit
Alt-Daten in der DB (vor diesem Fix angelegt) nicht klickbar
bleiben.

INFO-Konsolidierung: damit ist die Schema-/Host-Validierung
einheitlich an einer Stelle. Sanitize-Layer (stripHtml in
sanitize.ts) bleibt für reine Text-Felder zuständig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 11:48:14 +02:00
duffyduck c58a60db23 docs: todo.md + README aktualisiert (Pentest-Fixes, Folgezähler,
SIM-cardUser, EmailProvider-Label-Override etc.)

todo.md: rund 14 neue Erledigt-Einträge seit dem letzten Stand –
gruppiert nach Feature (Folgezähler-Workflow, Anzeige-/UX-Polishing,
Pentest 42.5/43.5/43.6) und im klassischen "kompakter Header +
Bullets"-Stil.

README.md:
- Zähler-Bullet um Lieferadress-Pflicht + Folgezähler-Kette ergänzt
- Strom/Gas-Vertragsfelder um Verbrauchs-Schätzwert aus Vorvertrag,
  HT/NT, Sofort-/Neukunden-Bonus, Folgezähler-Wechseldatum + Endstand
- SIM-Karten-Liste um "Kartennutzer" für Firmen-/Familienverträge

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 11:10:23 +02:00
duffyduck 9519f0dbca EmailProviders-Settings: Input "Bezeichnung im UI" für customerEmailLabel
Das customerEmailLabel-Feld existierte im Backend (samt Update-Logik
und Public-Endpoint), war aber im UI nicht erreichbar – das Label
wurde immer nur aus der Domain abgeleitet.

Neuer optionaler Input "Bezeichnung im UI" unter dem Domain-Hinweis.
Leer = automatisch aus Domain ableiten (bisheriges Verhalten),
ausgefüllt = überschreibt die Ableitung (z.B. "interne Kunden
Email Adressen" als Tab-Label).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:37:28 +02:00
duffyduck cd7075e96f Quicklinks auch im "Kein Postfach"-Zustand der E-Mails-Card anzeigen
Der "Stressfrei wechseln Adressen"-Link (sowie "Postfach öffnen")
war nur im Normal-Zweig sichtbar, nicht aber wenn der Kunde noch
gar kein Mailbox-Konto hat. cardTitle in einer gemeinsamen Variable
extrahiert und in beiden Branches verwendet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:23:40 +02:00
duffyduck 4acfd9de1c SIM-Karten: Feld "Kartennutzer" für Firmen-/Familienverträge
Bei Firmenverträgen (Vertragsinhaber = Firma, Nutzer = Mitarbeiter)
und Familienverträgen (Inhaber = Eltern, Nutzer = Kind) brauchten
wir ein Feld, das den tatsächlichen Nutzer der SIM-Karte erfasst.

Backend: SimCard.cardUser (String?, optional), Migration
20260601100000_sim_card_user mit IF NOT EXISTS. Im Service durch
Create + Update propagiert.

Frontend: Input "Kartennutzer" pro SIM-Karte in ContractForm
(eigene Zeile oberhalb der technischen Felder Rufnummer/SIM-Nr/
PIN/PUK). In ContractDetail wird der Nutzer als "Nutzer: <Name>"
neben den Hauptkarte/Multisim-Badges angezeigt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:10:16 +02:00
duffyduck 9e3bce85f0 Vorgängervertrag-Modal: Kundennr./Vertragsnr. beim Anbieter auch ohne Provider/Tarif anzeigen
Die "Anbieter & Tarif"-Card war nur sichtbar, wenn provider oder
tariff gesetzt waren. Bei Entwürfen ohne Anbieter wurden dadurch
auch customerNumberAtProvider + contractNumberAtProvider versteckt,
obwohl sie pflegbar sind und für den Wechsel-Workflow wichtig sind.
Fix: Card-Sichtbarkeitsbedingung um die beiden Felder erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 07:51:36 +02:00
duffyduck 4fb700cf57 Vertrags-Forms: Mini-Links zu Stammdaten in neuem Tab
ContractEmailsSection (Vertragsansicht): Zusätzlich zu "Postfach
öffnen" gibt es jetzt "Stressfrei wechseln Adressen" → Tab in der
Kundenakte.

ContractForm (Bearbeiten): Kleine ExternalLink-Icons neben den
Select-Labels:
- Lieferadresse + Rechnungsadresse → Kundenakte/Adressen
- Bankkarte → Kundenakte/Bankkarten
- Ausweis → Kundenakte/Ausweise
- Anbieter + Tarif → Settings/Anbieter & Tarife
- Vertriebsplattform → Settings/Vertriebsplattformen

Select-Komponente nimmt jetzt ReactNode als label (statt nur string),
um JSX-Labels mit eingebettetem Link zu erlauben. Rückwärts-
kompatibel zu allen bestehenden String-Aufrufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 07:47:40 +02:00
duffyduck 5269092d2a Fix: "Wurde sondergekündigt?"-Label nicht über volle Spaltenbreite klickbar
Label-Klasse war flex -> Block-Layout, das die ganze col-span-2-Zeile
einnimmt. Klicks rechts neben dem Text triggern dann ebenfalls die
Checkbox. Fix: inline-flex – die Label-Box passt sich an den Inhalt
(Checkbox + Text) an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 12:49:37 +02:00
duffyduck 83f1984f12 Pentest 43.6 MEDIUM + 43.5 INFO: History-XSS + blocked:-Marker
43.6 MEDIUM: ContractHistoryEntry.title + .description waren auf
beiden Pfaden ungestrippt – Admin konnte HTML/Script-Tags
einschreiben, Portal-User las sie roh zurück. Fix: stripHtml()
auf Create + Update (Write-Pfad) und sanitizeEntry() im List +
Get (Read-Pfad), damit Alt-Daten ebenfalls clean rausgehen.

43.5 INFO: stripHtml ersetzt javascript: -> blocked: – sinnvoll
bei URL-Feldern, hässlich in Tarif-/Preis-Namen ("blocked:alert(1)"
als Preis). Neuer stripForDisplay-Wrapper entfernt den Marker
zusätzlich in CONTRACT_DISPLAY_STRING_FIELDS + CUSTOMER_DISPLAY_
STRING_FIELDS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 19:58:20 +02:00
duffyduck b9a6d99d50 Pentest 42.5 MEDIUM: priceFirst12Months/priceFrom13Months/priceAfter24Months in Display-Strip aufnehmen
Die drei Preisfelder sind im Schema String? (freitextlich für
Angaben wie "0,28 €/kWh"). sanitizeContract strippte sie auf
dem Read-Pfad nicht – damit lieferten Alt-Daten mit XSS-Payloads
("<script>alert(1)</script>") sie 1:1 an die UI aus.

Defense-in-Depth: Write-Pfad hat sanitizeContractBody, das alle
String-Felder rekursiv stripped. Diese Read-Time-Variante
schützt zusätzlich vor Alt-Daten und einem kompromittierten
Admin-Account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 19:24:11 +02:00
duffyduck a20e331f83 Strom/Gas-Details: "Zähler verwalten"-Link neben Card-Titel
Zusätzlich zum bestehenden Link im Folgezähler-Form bekommt auch
der Card-Header der Strom/Gas-Details einen Link "Zähler verwalten",
der die Zähler-Übersicht des Kunden in einem neuen Tab öffnet –
damit der Link immer sichtbar ist, nicht nur wenn die Folgezähler-
Form aufgeklappt ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:11:01 +02:00
duffyduck 43aaf697a1 Fix: Multi-Meter-Verbrauch auf Vertragslaufzeit clampen
Bei Verträgen, die Vorgänger einer Folgevertrags-Kette sind, sind
über ContractMeter auch Folgezähler verknüpft, die nach Vertragsende
installiert wurden. Die Berechnung nahm cm.installedAt..cm.removedAt
1:1 ohne Clamp gegen Contract.startDate/endDate – damit flossen
Zählerstände aus der Folgevertrags-Phase in den Verbrauch dieses
Vertrags ein.

Fix: meterStart = max(installedAt, contractStart),
meterEnd = min(removedAt, contractEnd). Zähler komplett außerhalb
der Laufzeit werden übersprungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:07:19 +02:00
duffyduck b0e45c0ea0 Fix: "Zähler ohne Verträge anzeigen" filtert auf orphans, nicht additiv
Die Checkbox war falsch implementiert (additiv: zeigt auch Orphans).
Soll laut User filternd wirken: gecheckt = nur Zähler ohne Vertrag.

Logik:
- beide aus: alle aktiven Zähler (Default)
- nur "Inaktive": alle Zähler (aktiv + inaktiv)
- nur "ohne Verträge": aktive Zähler OHNE Vertrag
- beide an: alle Zähler ohne Vertrag (aktiv + inaktiv)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:00:21 +02:00
duffyduck 95b7261227 Anzeige-Fix: HTML in providerName/tariffName etc. beim Read strippen
In der Vertragsübersicht tauchen rohe <script>/<img>-Payloads als
Plaintext auf – React escaped sie zwar (kein XSS), sie sehen aber
hässlich aus. Ursprung: Daten aus pre-Pentest-Zeit, bevor
sanitizeContractBody beim Write existierte.

Fix: sanitizeContract und sanitizeCustomer strippen jetzt zusätzlich
HTML in den definierten Display-Feldern (providerName, tariffName,
customerNumberAtProvider, firstName, lastName, companyName, etc.).
Wirkt auch auf nested previousContract + energyDetails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:55:59 +02:00
duffyduck 0d024b94c2 Kundenakte → Zähler: Checkbox "Zähler ohne Verträge anzeigen"
Standardmäßig werden nur Zähler angezeigt, die mindestens einem
Vertrag zugeordnet sind (entweder als Hauptzähler oder über die
Folgezähler-Kette). Mit der neuen Checkbox lassen sich auch
verwaiste Zähler ins Listing holen – nützlich beim Aufräumen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:50:24 +02:00
duffyduck b4b0dbb004 Kundenakte → Zähler: Aufklapp-Liste der zugeordneten Verträge
Pro Zähler wird jetzt ein "Verträge (N)" Aufklapp-Bereich angezeigt,
der alle Verträge auflistet, die diesen Zähler nutzen – sowohl als
aktueller Hauptzähler (energyDetails.meterId) als auch über die
Folgezähler-Kette (ContractMeter). Dedupliziert auf contractId.

Jeder Eintrag ist Link auf den Vertrag im neuen Tab, mit
Vertragsnummer, Anbieter und Status-Badge. Folgezähler-Ketten-
Einträge werden mit "(über Folgezähler-Kette)" markiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:48:22 +02:00
duffyduck 2ee06630b9 Folgezähler-Forms: Checkbox "Alten Zähler deaktivieren" (default an)
Beide Folgezähler-Forms (Kundenakte MeterModal + Vertragsansicht
SuccessorMeterForm) bekommen eine Checkbox, die standardmäßig
angehakt ist. Beim Speichern wird der Vorgänger automatisch
auf isActive=false gesetzt – ein-klick-fähiger Zählerwechsel.

Backend: createMeter mit successorOf und addSuccessorMeter
akzeptieren deactivatePredecessor (Default true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:39:05 +02:00
duffyduck 3a9cece929 Vertragshistorie: Vertragsnummern als Link in neuem Tab
Erwähnte Vertragsnummern (Pattern PREFIX-RANDOM) in Title und
Description werden gegen previousContract + followUpContract des
aktuellen Vertrags aufgelöst und als Link mit target="_blank"
gerendert. Nicht aufgelöste Nummern bleiben als Text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:33:11 +02:00
duffyduck e527aebb84 Vorvertrag-Verbrauch als Schätzwert im Folgevertrag
ContractForm (Strom/Gas): Wenn ein previousContractId gesetzt ist,
wird der Vorvertrag samt Readings nachgeladen, der Verbrauch
clientseitig berechnet und als "Vorvertrag: X kWh [Übernehmen]"
unter dem Jahresverbrauch-Feld angezeigt. Bei Gas auch unter
"Jahresverbrauch (kWh)".

ContractDetail (Strom/Gas): Wenn annualConsumption leer ist und
ein berechenbarer Vorvertrag existiert, wird "~X kWh, geschätzt
aus Vorvertrag" in der Jahresverbrauch-Zelle angezeigt – damit
der Wert beim Lesen schon als Anhaltspunkt da steht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:28:55 +02:00
duffyduck 13213846f4 Folgezähler-Form: Link "Zähler verwalten" → Kundenakte (neuer Tab)
Wenn im Vertrag kein passender Zähler im Dropdown auftaucht, kann
der User mit einem Klick die Zähler-Übersicht des Kunden in einem
neuen Tab öffnen, um dort einen neuen Zähler anzulegen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:15:40 +02:00
duffyduck 61ce35821d Endstand alter Zähler fließt in Verbrauchsberechnung ein
Bisher wurde "Letzter Stand alter Zähler" zwar in
ContractMeter.finalReading gespeichert, aber nirgends ausgewertet.

Neuer Helper recordPredecessorFinalReading legt am Wechseldatum
einen regulären MeterReading-Eintrag für den Vorgänger an
(idempotent, mit Validierung gegen vorhandene Stände). Aufgerufen
aus addSuccessorMeter (Vertragsansicht) und createMeter mit
successorOf (Kundenakte).

Folge: Der Endstand erscheint in der Zählerstände-Liste des alten
Zählers und fließt automatisch über calculateMultiMeterConsumption
in den Verbrauch (Zeitraum bis removedAt ist inklusive).

UI-Hinweise in beiden Folgezähler-Forms erklären den Effekt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:14:03 +02:00
duffyduck 34e106f253 Fix: Folgezähler-Button auch bei Single-Meter-Verträgen anzeigen
Bei Folgeverträgen / Bestandsverträgen ohne ContractMeter-Eintrag
war der "Folgezähler hinzufügen"-Button unsichtbar, weil er nur
im Multi-Meter-Zweig gerendert wurde.

Zusätzlich im addSuccessorMeter-Backend: bei Single-Meter-Verträgen
wird der bisherige energyDetails.meterId jetzt als ContractMeter
position 0 backfillt und als removed markiert, damit die Kette
lückenlos ist und der alte Zähler im Vertrag dokumentiert bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:03:01 +02:00
duffyduck ad4c2bae1d Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)
- Meter.predecessorMeterId (Self-Relation) + Migration
  20260530140000_meter_predecessor mit IF NOT EXISTS
- createMeter akzeptiert optional successorOf:
  {predecessorMeterId, installedAt?, finalReadingPrevious?}.
  Vorgänger wird validiert (gleicher Kunde + Typ); alle Verträge
  mit dem Vorgänger als aktuellen Zähler werden analog zu
  addSuccessorMeter automatisch auf den neuen Zähler umgestellt
  (ContractMeter-Eintrag mit removedAt/finalReading für den
  Vorgänger, neuer ContractMeter mit installedAt + nächster
  Position, energyDetails.meterId aktualisiert)
- MeterModal: Checkbox "Als Folgezähler deklarieren" + Dropdown
  Vorgänger + Wechseldatum + Endstand. Typ/Tarifmodell/Adresse
  werden vom Vorgänger übernommen und disabled. Info-Banner über
  Vertragsauto-Update

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:48:23 +02:00
duffyduck 6f378d750c Vertragsansicht: Standort + Inaktiv-Badge beim Zähler anzeigen
In der Zähler-Zelle der Strom/Gas-Details wird jetzt zusätzlich
"Inaktiv" (rot) neben der Zählernummer angezeigt und der
Standort als kleine Zeile darunter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:32:12 +02:00
duffyduck c099b41796 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter
- Meter.addressId (FK → Address, ON DELETE SET NULL) + Migration
  20260530100000_meter_address mit IF NOT EXISTS
- Service erzwingt beim Create: Lieferadresse vorhanden + zum
  Kunden gehörig + Typ DELIVERY_RESIDENCE
- MeterModal: Pflicht-Dropdown "Lieferadresse"; Save disabled
  ohne Adresse; Hinweis-Banner. Bestandszähler ohne Adresse zeigen
  "nicht zugeordnet – bitte über Bearbeiten nachpflegen"
- ContractForm: Zähler-Dropdown filtert auf Vertrags-Lieferadresse;
  deaktivierte Zähler bleiben sichtbar mit "(deaktiviert)"; bei
  Auswahl Toast-Warnung wegen möglichem Altvertrag

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:18:24 +02:00
duffyduck d92d0b1eaf Vertrags-Mail-Card: Link "Postfach öffnen" → Kunden-Postfach in neuem Tab
Neben dem "E-Mails"-Titel der Card im Vertragsdetail jetzt ein
kleiner Link mit ExternalLink-Icon, der den Tab "E-Mail-Postfach"
in der Kundenakte in einem neuen Tab öffnet (target="_blank",
rel="noopener noreferrer"). Greift auf das bereits unterstützte
?tab=emails-Deep-Link-Pattern in CustomerDetail zurück.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 12:23:29 +02:00
duffyduck a3fef8891a Pentest 2026-05-30 LOW 39.3 + INFO 39.4: Magic-Byte-Check + Endung-Normalisierung
Upload-Endpoints (/api/upload/...) hatten denselben Mismatch-Vektor
wie schon die Vollmacht-Route (Pentest 28.3): multer prüft nur den
client-gemeldeten MIME-Type, eine `.php`-Datei mit
Content-Type: image/gif rutschte durch und landete als
`<unique>.gif.php` (Doppel-Endung) auf Disk – kein RCE in unserem
Setup, aber dreckige Datei + Inkonsistenz zwischen geliefertem MIME
und tatsächlichem Inhalt.

Fix: neue validateUploadedFile-Middleware nach upload.single(...) –
- liest die ersten 12 Bytes der gerade geschriebenen Datei
- erkennt PDF/PNG/JPEG/GIF/WebP per Magic-Bytes
- bei Mismatch: Datei löschen + 415 "Datei-Inhalt entspricht keinem
  zulässigen Typ"
- benennt die Datei auf eine KANONISCHE Endung (.pdf/.jpg/.png/.gif/
  .webp) um, abgeleitet aus dem erkannten Typ (NICHT aus
  file.originalname). Damit verschwindet `evil.gif.php` zu
  `<unique>.gif` (39.4).
- setzt req.file.mimetype auf den erkannten Type, sodass Controller
  konsistente Werte sehen.

Eingehängt in allen 10 upload.single('document')-Routes
(bank-cards, documents, business-registrations, commercial-register,
contract-docs etc.).

Live-verifiziert:
- PHP-Datei als image/gif    → 415 + Datei gelöscht
- HTML-Datei als application/pdf → 415 + Datei gelöscht
- WebP-Inhalt mit MIME image/png → 200, gespeichert als .webp
- echtes WebP/JPG → 200 mit kanonischer Endung

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 11:43:13 +02:00
duffyduck 617022e492 Multer-Upload-Errors: 415/413 statt 500
Pentest 2026-05-30 INFO: Upload-Endpoints lieferten 500 statt
sauberem 4xx, wenn der fileFilter den MIME-Type ablehnte
(z.B. WebP/GIF, die gar nicht in der Allowlist standen) oder
LIMIT_FILE_SIZE getroffen wurde.

Ursache: fileFilter rief cb(new Error(...)) – multer wirft das
weiter, und ohne dedizierten Error-Handler endete es als 500
"Interner Serverfehler" mit Stack-Trace im Log.

Fix:
- WebP + GIF in die Allowlist von upload.routes.ts (Bug-Pen-
  test-Erwartung des Reporters).
- Globaler Express-Error-Handler in index.ts unterscheidet jetzt:
    * MulterError code=LIMIT_FILE_SIZE → 413 "Datei ist zu groß"
    * andere MulterError                → 400 "Upload-Fehler: ..."
    * Error mit "...erlaubt"-Message    → 415 mit Original-Message
    * sonst                             → bisheriger 4xx/500-Pfad

Live-verifiziert:
  WebP/GIF/JPG → 200
  SVG / text/plain → 415 + klare Message
  11 MB PDF → 413 "Datei ist zu groß"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:59:06 +02:00
duffyduck c93d4375ab fix: Email-Anhang öffnet wieder zuverlässig im neuen Tab
Manche Mail-Clients setzen für PDF-Anhänge fälschlich
Content-Type: application/octet-stream (oder application/x-pdf,
"PDF Document" usw.). Der bisherige Whitelist-Check fiel dann
auf Content-Disposition: attachment zurück – der Browser hat
trotz target="_blank" am <a>-Tag KEINEN neuen Tab geöffnet,
sondern die Datei direkt im aktuellen Tab "geöffnet" (Download
oder native PDF-Anzeige), je nach Browser-Konfiguration. Effekt
für den User: Klick auf Vorschau-Icon → Vorschau ersetzt das
CRM-UI.

Fix: Magic-Byte-Detection direkt am Buffer (gleiche Logik wie
beim /api/files/download-Endpoint). PDF/PNG/JPEG/GIF/WebP werden
zuverlässig erkannt, der vom IMAP gemeldete Type wird ignoriert
(real-world unzuverlässig). Bei Match → inline mit erkanntem
Type; sonst attachment + octet-stream. text/plain bleibt durch
einen schwächeren Sniff-Check zugelassen, sofern keine HTML-
Tags am Anfang stehen.

Stored-XSS-Schutz unverändert: HTML-Anhang mit .pdf-Endung →
kein PDF-Magic → kein inline → attachment + octet-stream → kein
Browser-Rendern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:24:13 +02:00
duffyduck 0bd2f9be7e fix: "Anzeigen"-Buttons öffnen Datei wieder im Browser-Tab
Folge-Symptom des Pen-30.13-Fixes: alle file-downloads liefen mit
Content-Disposition: attachment – das ist gegen Stored-XSS richtig,
hat aber die "Anzeigen"-Buttons (Bankkarten / Ausweise /
Verträge / etc.) kaputtgemacht, weil der Browser jetzt
herunterlud statt im Tab zu öffnen.

Magic-Byte-basierter Whitelist-Pfad eingebaut: optional ?disposition=
inline am Download-Endpoint, ABER nur wenn die ersten Bytes der
Datei das Magic eines safe Typs zeigen (PDF, PNG, JPEG, GIF, WebP).
Bei Mismatch fällt's auf attachment zurück – Stored-XSS bleibt
weiterhin unmöglich, falls jemand HTML als .pdf hochlädt.

Frontend: neuer viewUrl(path)-Alias = fileUrl(path, {inline: true}).
Alle Stellen mit `<a href={fileUrl(...)} target="_blank">` oder
`window.open(fileUrl(...), '_blank')` (13 Stellen über CustomerDetail,
ContractDetail, PdfTemplates, GDPRDashboard, InvoicesSection)
nutzen jetzt viewUrl. Download-Stellen bleiben fileUrl
(= attachment, byte-genaues File-Save).

Live-verifiziert auf dev:
- ohne Param: attachment (default, Stored-XSS-Schutz)
- ?disposition=inline + echte PDF: inline + application/pdf
- ?disposition=inline + HTML als .pdf: attachment (Magic-Mismatch
  → Browser lädt herunter statt zu rendern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:19:04 +02:00
duffyduck 6a670df1c4 fix: 2x Portal-Bugs (Vertragsauswahl + Email-Sync)
Bug 1 — Support-Anfrage: ausgewaehlter Vertrag nicht erkennbar
Im Kundenportal beim Erstellen einer Support-Anfrage war der
Selected-State des Vertrags nur ein dezenter blau-grauer
Hintergrund + Border-Farbwechsel. Auf hellem Bildschirm / nicht-
perfekter Lichtsituation kaum zu sehen.

Fix: kraefigere Markierung mit linkem 4px-Akzent-Bar
(border-l-blue-600), kraefigerem Background (bg-blue-100),
Checkmark-Icon rechtsbuendig und blauer Titel-Text.

Bug 2 — Email-Sync im Portal: "Keine Berechtigung"
POST /api/stressfrei-emails/:id/sync hatte
requirePermission('customers:update') – die Portal-Kunden nicht
haben (nur customers:read fuer eigene Daten). Sie konnten ihr
eigenes Postfach nicht synchronisieren.

Fix: Perm-Middleware aus der Route raus, Mitarbeiter-Check +
Owner-Check in den Controller verlegt:
- isCustomerPortal: nur Owner-Check (canAccessStressfreiEmail)
- Mitarbeiter: muss customers:update haben
Trennung der Threat-Modelle – Portal-User darf sein Postfach
syncen, sonst aber nichts triggern; Mitarbeiter brauchen weiter
die Update-Perm.

Live-verifiziert:
- Portal-User 1 syncs eigenes Konto → Auth passiert (400 wegen
  fehlender IMAP-Config in dev-DB, NICHT 403)
- Portal-User 1 syncs Customer-3-Konto → 403 "Kein Zugriff"
- Mitarbeiter ohne customers:update → weiter 403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 08:25:16 +02:00
duffyduck 7dcdf9d6ef Pentest Runde 35 follow-up: portalLoginUrl blockt ALLE privaten IPs
Runde-35-Befund: 34.5 nur teilweise gefixt – Cloud-Metadata
(169.254.x.x) wurde blockiert, aber 10/8, 172.16/12, 192.168/16,
127/8 und localhost gingen weiter durch, weil isBlockedSsrfHost
diese Ranges nur mit SSRF_BLOCK_PRIVATE_IPS=true geprüft hat. Der
Flag steht aber bewusst auf false für on-prem (Plesk auf 127.0.0.1).

Threat-Modell-Unterschied: portalLoginUrl ist eine URL in
*Endkunden-Mails*. Kunden können 127.0.0.1/192.168.x.x ohnehin nicht
erreichen → kein legitimer Wert. Daher muss der Check hier strikt
sein, unabhängig vom on-prem-Flag (der gilt nur für ausgehende
Server-zu-Server-Verbindungen wie Provider-Test-Connection).

Neuer isPrivateOrBlockedHost() in ssrfGuard.ts: union aus
BLOCKED_PATTERNS (Metadata/Multicast/Reserved) und
PRIVATE_IP_PATTERNS (10/8, 172.16/12, 192.168/16, 127/8, ::1,
fc00::/7) + PRIVATE_HOSTNAMES (localhost, ip6-loopback), egal was
SSRF_BLOCK_PRIVATE_IPS sagt.

portalLoginUrl-Validator nutzt jetzt isPrivateOrBlockedHost +
strippt eckige Klammern aus IPv6-Hostnames (Node URL.hostname
liefert "[::1]" inkl. Brackets).

Live-verifiziert: 22 Test-Cases (9 Private/Loopback, 4 Schemes,
7 legitime). Auch CIDR-Grenzen (172.15 zulässig, 172.16/31
blockiert, 172.32 zulässig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:55:44 +02:00
duffyduck 100147107c Pentest 2026-05-28 LOW 34.5: Backend-Validierung für AppSettings
Schema-Whitelist und Trailing-Slash-Strip für portalLoginUrl standen
NUR im Frontend. Der API-Endpoint nahm sonst /relative/path,
javascript:/ftp:/data:-Schemata und private IPs ungeprüft entgegen –
das landet als toter / bösartiger Link in den an Kunden verschickten
Portal-Mails (Open-Redirect / SSRF-Vektor).

Neuer validateSettingValue(key, value) in appSetting.service mit
per-Key-Logik:
  - portalLoginUrl: absolute http(s)-URL, isBlockedSsrfHost-Check
    (Cloud-Metadata immer, private Ranges via SSRF_BLOCK_PRIVATE_IPS),
    Trailing-Slash-Strip.
  - Schwellenwerte (deadline*/documentExpiry*): positive Integer.
  - Bool-Settings: strict 'true'/'false'.
  - monitoringAlertEmail: RFC-5322-light gegen Header-Injection.
  - Andere Keys: kein Format-Check (Default).

Controller (updateSetting + updateSettings) rufen Validator nach
stripHtml; bei Fehler HTTP 400 mit klarer Message. Bulk-PUT
validiert ALLE Werte VOR dem ersten DB-Write – kein halb-committed
State bei einem ungültigen Eintrag.

Live-verifiziert auf dev: alle Test-Payloads aus dem Pentest
sauber abgelehnt, legitime Werte (https-URL, Trailing-Slash, Pfade)
korrekt akzeptiert + normalisiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:39:45 +02:00
duffyduck 2d4e4cdcc7 Portal-Login-URL als App-Setting (statt nur PUBLIC_URL-Env)
Bugfix: in der "Zugangsdaten versenden"-Mail stand bisher
http://localhost:5173/portal/login als Login-Link, wenn die
PUBLIC_URL-Env nicht gesetzt war – Kunden klickten auf einen
toten Link.

Neue Einstellung "portalLoginUrl" unter Einstellungen → Kundenportal.
Wenn gepflegt, wird sie als Basis-URL für:
  - Portal-Zugangsdaten-Mail (Login-Link)
  - Passwort-Reset-Link
verwendet. Reihenfolge: AppSetting → PUBLIC_URL-Env → localhost-Default.

Backend: getPublicUrl() jetzt async, liest erst aus AppSetting,
fällt auf Env zurück. Trailing-Slash-Bereinigung im Backend
(damit Links nicht doppelt-Slash bekommen) und im Frontend
(damit der gespeicherte Wert sauber ist).

Frontend: neue Card "Portal-Login-URL" oberhalb der Support-
Anfragen-Card in PortalSettings.tsx. Input + Save-Button +
http(s)://-Schema-Validierung + Erfolgs-Toast.

Live-verifiziert: PUT setzt 'https://crm.beispiel.de', Backend-
getPublicUrl liefert 'https://crm.beispiel.de/portal/login'
statt localhost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:49:55 +02:00
duffyduck ee4ca9df07 Zugangsdaten-Card: Portal-Link des Anbieters anzeigen
Im Vertragsdetail unter "Zugangsdaten" zwischen Benutzername und
Passwort jetzt eine zusätzliche Zeile "Portal-Link" mit klickbarem
Link zum Anbieter-Portal (öffnet in neuem Tab, mit Copy-Button).
Greift auf das bestehende c.provider.portalUrl-Feld zurück (wird
auch schon für den Auto-Login-Button verwendet).

Schema und Host werden im Anzeigetext gestrippt, die volle URL
bleibt im href und im title-Attribut sichtbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:27:02 +02:00
duffyduck 9385fc0f11 fix: Portal-E-Mail-Feld konnte nur per Paste befüllt werden
Folge-Symptom des Pentest-29.4-Email-Validators: das Portal-Email-
Input feuerte bei jedem Keystroke einen PUT
/customers/:id/portal mit dem Zwischenstand ("p", "po", "por@") –
der Backend-Validator lehnte das mit 400 ab, der Server-State blieb
unverändert, das Input re-renderte mit dem alten Wert. Effekt: man
konnte nichts tippen, nur per Paste in einem Event eine
vollständige Adresse setzen.

Fix: lokaler emailDraft-State. Während getippt wird, bleibt der
Wert nur im Client. Commit erfolgt erst onBlur oder bei Enter –
oder wird mit Escape verworfen. Bei Mutations-Error gibt's jetzt
auch einen toast statt stiller Revert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:20:36 +02:00
duffyduck c9f4fcf8de fix: Portal-Passwort-Card im Vertragsdetail wieder sichtbar
Folge-Symptom zum PW-Save-Fix: das Speichern hat funktioniert,
aber die "Zugangsdaten"-Card im Read-Only-View hat das Passwort-
Feld nicht angezeigt. Ursache: das Frontend nutzte
`c.portalPasswordEncrypted` als Truthy-Check, aber
sanitizeContract strippt das Feld bewusst aus jeder Response
(Pentest Runde 15 - kein verschlüsselter Blob in /contracts/:id).

Fix: getContractById hängt jetzt ein virtuelles `hasPortalPassword`-
Bool-Flag an die Response. Frontend nutzt das statt
portalPasswordEncrypted. Der verschlüsselte Wert bleibt
server-seitig; der Klartext kommt weiterhin über
GET /contracts/:id/password mit Audit-Log.

Live-verifiziert: PUT setzt PW, GET liefert hasPortalPassword:true
+ portalPasswordEncrypted ist NICHT in der Response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:30:54 +02:00
duffyduck f41d1843e4 fix: Portal-Passwörter im Vertrag wurden mutiliert
Folgefehler aus Pentest 31.1: die rekursive sanitizeContractBody()
lief auch über portalPassword. Passwörter mit HTML-Pattern
("Pass<TAG>word!" → "Password!") oder URI-Schema-Prefix
("data:secret" → "blocked:secret") wurden vom stripHtml-Strip
zerstört, bevor die Service-Schicht sie verschlüsseln konnte.

Fix: PASSTHROUGH_KEYS = {portalPassword, password}. Beim Walk
werden String-Werte unter diesen Keys NICHT gefiltert. Passwort
wird sowieso encrypt()-verschlüsselt in die DB geschrieben und
niemals als HTML ausgegeben – kein XSS-Risk.

Live-verifiziert:
- PUT portalPassword="MyP@ss<word>123!&data:foo"
  → GET /password decrypt liefert byte-identischen Wert
- PUT providerName="<script>...EvilProvider" → DB: "EvilProvider"
  (XSS-Schutz weiter aktiv)
- PUT portalUsername="u<test>" → DB: "u" (Plain-Text-User wird
  weiter gestrippt, ist kein Passwort)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:05:26 +02:00
duffyduck aa0900410b Pentest 2026-05-24 Pen-31-Befunde (2x MEDIUM)
31.1 Stored XSS in Vertragsfeldern:
providerName, tariffName, priceFirst12Months, priceFrom13Months,
priceAfter24Months nahmen rohe HTML-/Script-Payloads (<script>,
<svg/onload>, <img onerror>, javascript:, HTML-Entities) an und
lieferten sie 1:1 an Portal-User zurueck.

Fix: rekursiver sanitizeContractBody()-Walker im contract.controller,
strippt String-Werte ueber das bestehende stripHtml() (Tag-Strip +
URI-Schema-Block + Entity-Decode). Verträge enthalten keine legitimen
HTML-Felder, deshalb safe. Audit-Vergleich nutzt jetzt die
sanitisierte Variante, sonst Audit ↔ DB-Drift.

31.2 IDOR auf GET /api/customers/:id/stressfrei-emails (+5 weitere):
requireCustomerAccess short-circuitete auf customers:read. Portal-
User haben aber genau diese Perm im JWT (für eigene Daten) – damit
kam Portal-Kunde 1 an Adressen/Bank-Cards/Documents/Meters/
Stressfrei-Emails von Kunde 3.

Fix im Middleware: erst isCustomerPortal-Check (eigene + vertretene
IDs), DANN erst Perm-Check für Mitarbeiter. Mit einem Patch alle
sechs requireCustomerAccess-Routes dicht. Defense-in-Depth:
zusätzlicher canAccessCustomer-Call in
stressfreiEmail.getEmailsByCustomer analog zum POST-Handler.

Live-verifiziert auf dev:
- Portal-User 1 → Customer 3: alle 6 Routes 403
- XSS-Payloads in 5 Contract-Feldern → DB enthält bereinigte Werte

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:38:16 +02:00
duffyduck 897abc7b21 Datenschutzerklärung als unterschreibbare PDF-Vorlage
Neuer Endpoint GET /api/gdpr/customer/:customerId/privacy-pdf
generiert eine PDF mit:
- Titel
- Personalisiertem Kopf (Name / Firma + Kundennummer + Datum)
- Voller Datenschutzerklärung (HTML → Text)
- Einwilligungsklausel
- Unterschriftenblock (Ort/Datum links, Unterschrift rechts,
  zweite Linie "Name in Druckbuchstaben" mit vorausgefuelltem
  Kundennamen)

Auth: customers:read + canAccessCustomer. Filename:
"datenschutzerklaerung-<kundennummer>.pdf".

Im Tab "Einwilligungen / Datenschutz" beim Kunden gibt es jetzt
direkt neben dem Upload-Feld den Link "Vorlage zum Unterschreiben"
– Ausdrucken, unterschreiben lassen, scannen, wieder hochladen.

Verifiziert auf dev: Magic-Bytes %PDF-1.3, %%EOF-Marker am Ende,
2 KB Output, pdftotext zeigt korrekten Aufbau inkl. Unterschrift-
Linien.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:30:11 +02:00
duffyduck 69a52ffe03 README: Admin-Initial-Passwort ist seit Pentest-12 zufällig
Die README versprach weiterhin "admin@admin.com / admin" als Default,
aber Pentest Runde 12 hat das hardcoded "admin" entfernt
(Komplexitäts-Policy-Verletzung). Der Seed generiert jetzt ein
28-Zeichen-Zufallspasswort und schreibt es einmal nach stdout.

Aktualisiert:
- Quick-Start-Header: Hinweis statt direktes Passwort
- "Erste Inbetriebnahme"-Block: docker-logs-Befehl + SEED_ADMIN_PASSWORD-Alternative
- "Erster Login"-Sektion: vollständige Anleitung inkl. Beispiel-Ausgabe
- "Production-Deployment"-Checkliste: aktualisiert
- .env.example: SEED_ADMIN_PASSWORD-Block dokumentiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:56:55 +02:00
duffyduck 771f46d2ac Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Input
Feature 1 – Kunden-Schnellansicht:
Info-Icon neben dem Kundenlink im Vertragsdetail oeffnet ein Modal
mit den wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
Gruendungsdatum, Adresse, Telefon, Mobil, E-Mail, Portal-E-Mail,
Steuer-/Handelsregisternr). Jedes Feld hat einen Copy-Button.
Lazy-Fetch via customerApi.getById, staleTime 30s.

Feature 2 – Cent/Euro-Doppel-Input:
Neben dem €/kWh-Arbeitspreis-Feld jetzt ein zweites ct/kWh-Feld.
Bidirektional gekoppelt – Tippen in € aktualisiert ct (×100),
Tippen in ct aktualisiert € (÷100). Backend speichert weiterhin
nur den Euro-Wert; Cent ist reine UI-Hilfe. Float-Rausch-Schutz
verhindert "0.25 → 25.0000000000004". Greift fuer unitPrice und
unitPriceNt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:41:37 +02:00
duffyduck 20d42c5270 Energie-Bonus aufgeteilt in Sofort + Neukunden
EnergyContractDetails.bonus war ein einzelnes Feld. Strom-/Gas-
Verträge haben aber typischerweise zwei Boni (Sofort beim Wechsel
+ Neukunden-Bonus nach 12 Monaten), die getrennt verbucht werden
müssen.

Migration 20260524100000_split_energy_bonus:
- ADD COLUMN IF NOT EXISTS instantBonus, newCustomerBonus
- bestehende `bonus`-Werte → instantBonus (Annahme: Sofort)
- DROP COLUMN IF EXISTS bonus

UI:
- ContractForm zeigt zwei Input-Felder
- Detail-Ansicht zeigt beide einzeln + Gesamtbonus
- Kostenvorschau listet beide einzeln, dann Gesamt, dann effektive
  Jahreskosten

Cost-Calc: calculateCosts() bekommt beide Boni; CostCalculation
liefert instantBonus, newCustomerBonus, totalBonus.

PDF-Template: drei neue Variablen energyDetails.instantBonus,
.newCustomerBonus, .totalBonus.

Live-verifiziert auf dev: PUT mit beiden Werten → DB persistiert,
GET liefert zurueck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:27:54 +02:00
duffyduck 92c3b0dc95 docker-compose: SSRF_BLOCK_PRIVATE_IPS-Env durchreichen
Damit der Flag aus der .env auch im Container ankommt – Default
false (on-prem-kompatibel), Cloud-Deploys setzen in der .env
SSRF_BLOCK_PRIVATE_IPS=true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:18:55 +02:00
duffyduck 83cd737e81 README: SSRF_BLOCK_PRIVATE_IPS dokumentiert (Cloud-Deploy)
Neue Section "Deployment-Modus: On-Prem vs. Cloud" im Production-
Deployment-Block. Erklaert, warum On-Prem-Default private IPs
erlaubt (Plesk/Dovecot lokal) und wann der Flag fuer Cloud-Deploys
auf true gesetzt werden soll. Cloud-Metadata-Endpoints sind
unabhaengig vom Flag immer geblockt.

.env.example: SSRF_BLOCK_PRIVATE_IPS=false als Default mit Block-
Kommentar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:17:05 +02:00
duffyduck a95aa384a2 Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)
30.13 MIME-Extension-XSS (MEDIUM):
GET /api/files/download lieferte hochgeladene Dateien via
res.sendFile() aus. Da multer nur den client-gemeldeten MIME prueft,
konnte eine als application/pdf deklarierte .html-Datei auf Disk
landen – Express liest beim Senden den Content-Type aus der Extension
(text/html), Browser haette gerendert → Stored XSS.

Fix: Content-Disposition: attachment + safe filename. Browser laedt
jetzt herunter statt zu rendern, egal welcher Content-Type. UX-Cost
ist gering (PDF-Preview offnet halt aus dem Download-Ordner).
X-Content-Type-Options: nosniff bleibt zusaetzlich gesetzt.

30.14 SSRF Private-IP-Block opt-in (INFO):
ssrfGuard erlaubte private IPs (127/10/172.16/192.168) bewusst, weil
On-Prem-Setups Plesk/Dovecot/Postfix lokal laufen lassen. Fuer
Cloud-Deployments ist das ein SSRF-Vektor. Neuer Env-Flag
SSRF_BLOCK_PRIVATE_IPS=true erweitert die Block-Liste um alle
privaten Ranges + ::1 + fc00::/7 + IPv4-mapped + localhost/
ip6-localhost. Default off (on-prem-kompatibel).

Live-verifiziert auf dev:
- Download-Header: Content-Disposition: attachment + safe filename
- Default: 127.0.0.1/10.x/192.168.x/localhost durchgelassen,
  169.254.169.254 (Cloud-Metadata) weiter geblockt
- SSRF_BLOCK_PRIVATE_IPS=true: alle privaten Ranges geblockt,
  8.8.8.8 (legitim) durchgelassen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:14:59 +02:00
duffyduck 9cf8c505af Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)
28.1 Restarbeit (URI-Schemata):
DANGEROUS_URI_SCHEMES jetzt vollstaendig – blob:, about:, ws:, wss:,
ldap:, dict: ergaenzt. http(s):, mailto:, tel: bewusst nicht
geblockt (legitime URLs in Notizfeldern).

29.1 Cyrillic-Homoglyph:
"jаvascript:" mit U+0430 lief durch die Regex. HOMOGLYPH_TO_ASCII-
Map (а→a, е→e, о→o, …, 13 Eintraege) wird VOR dem Scheme-Strip
angewendet.

29.2 Percent-Encoding:
"java%73cript:" und "java%2573cript:" umgingen den Filter.
percentDecode() laeuft jetzt iterativ bis zu 5 Runden.

29.3 Zero-Width-Joiner:
"j​av​ascript:" mit U+200B/200C/200D etc. zerteilte die Regex-
Matches. ZERO_WIDTH_CHARS-Regex strippt alle unsichtbaren Unicode-
Steuerzeichen, bevor irgendwas anderes laeuft.

28.3 Partial (PDF-Validierung tiefer):
Magic-Bytes allein reichten nicht – "%PDF-1.4\n#!/bin/bash" kam
durch. Jetzt zusaetzlich %%EOF-Marker in den letzten 1 KB +
Pattern-Scan der ersten 4 KB auf #!/, <script, <?php, <%, "MZ "
(PE-Header).

29.4 Email-Format-Validator:
neuer isValidEmail() lehnt Whitespace/Newlines (SMTP-Header-
Injection-Vektor) und Format-Muell ab. Verdrahtet in
create/update Customer + User + updatePortalSettings.

29.5 GET /api/providers/email 500 -> 404:
parseInt("email") = NaN, Prisma crashte. Controller validiert jetzt
Number.isFinite(id) und liefert 404.

Live-verifiziert auf dev: 13 Test-Cases (alle Schema-Varianten,
Homoglyphe, Percent, ZWJ, PDF-Validierung, Email-Format,
/providers/email) – alle erwarteten Antworten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:47:44 +02:00
duffyduck 65ec07e274 Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)
28.1 URI-Schema unvollstaendig:
DANGEROUS_URI_SCHEMES erweitert um file:/ftp: – "ftp://evil.com/x.js"
und "file:///etc/passwd" wurden vorher in companyName akzeptiert.

28.2 HTML-Entity-Decoding-Bypass:
stripHtml() lief direkt ueber den Roh-String, "&#106;avascript:",
"&#x3C;script&#x3E;" und "&lt;script&gt;" umgingen die Regex.
decodeHtmlEntities() dekodiert jetzt numerische (decimal+hex) +
gaengige named entities VOR dem Tag-/URI-Strip.

28.3 Vollmacht-Upload Magic-Byte-Check:
multer pruefte nur client-MIME, HTML/PHP/Shell-Scripts kamen als
application/pdf durch. uploadAuthorizationDocument liest jetzt die
ersten 5 Bytes und verlangt "%PDF-", sonst Loeschen + 400.

28.4 Rate-Limit auf /api/public/consent:
30 Requests pro IP pro 15min. Brute-Force-sicher war der 128-bit-
UUID-Hash schon, aber ohne Limit konnte ein Angreifer das System
mit Audit-Log- und Mail-Spam belasten.

Live-verifiziert auf dev: alle vier Bypaesse blockiert, legitime
Eingaben unangetastet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:46:15 +02:00
duffyduck 8e48d3b432 Pentest 2026-05-20 LOW/INFO Sammelfix
27.1 Path-Traversal-Strings in DB:
- cleanupConsents validierte documentPath zuvor nur per stripHtml,
  ließ "../../../etc/passwd" durch. Neuer isValidDocumentPath-Check
  akzeptiert nur "/uploads/<safe>", alles andere → NULL.
- cleanupDocumentPaths scannt fünf weitere Tabellen (BankCard,
  IdentityDocument, Invoice, RepresentativeAuthorization nullable;
  ContractDocument NOT NULL → nur Report).

Orphaned User:
- reportOrphanedUsers warnt beim Container-Start vor User ohne
  Rollenzuordnung (im Permission-System unsichtbar). Löschen nicht
  automatisch wegen False-Positive-Risiko.

Seed-PW-Policy:
- generateInitialPassword() nutzte Math.random() (vorhersagbar).
  Jetzt crypto.randomInt() für Pick + Fisher-Yates-Shuffle.

PUT /users/:id mit permissions / password:
- Vorher silent-drop durch Whitelist + HTTP 200, Caller glaubte
  faelschlich, Werte waeren uebernommen. Jetzt HTTP 400 mit
  konkreter Hilfe-Message.

/api/health ohne Auth:
- Pentest-Befund INFO: bewusst so, Container-Healthcheck und
  Reverse-Proxy pingen ohne Bearer-Token. Antwort liefert nur
  {status,timestamp} – keine Version, kein DB-Status, kein
  Info-Leak. Comment im Code dokumentiert die Entscheidung.

Live-verifiziert auf dev: alle fuenf Findings durchgetestet,
jeweils mit dirty Input → erwartete Sanitization/Antwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:49:06 +02:00
duffyduck adc3b70492 Pentest 2026-05-20 MEDIUM+LOW Follow-ups
MEDIUM – Consent-Mass-Assignment:
PUT /api/gdpr/customer/:id/consents/:type nahm source/documentPath/
version ungefiltert aus dem Body. Portal-User konnte
source="ADMIN_OVERRIDE", version="<script>" oder
documentPath="../../etc/passwd" durchschmuggeln.

Fix: nur status aus Body, source server-seitig auf "portal"
hardcoded, documentPath/version bleiben NULL (werden dediziert
vom Authorization-Upload server-seitig gesetzt). Whitelist
ALLOWED_CONSENT_SOURCES für source-Werte. grantAuthorization
(Admin) erzwingt die Whitelist ebenfalls; notes läuft jetzt
durch stripHtml.

LOW – javascript:-URI in companyName:
stripHtml() entfernte HTML-Tags, ließ aber javascript:/data:/
vbscript:-Schemata stehen. companyName="javascript:alert(1)"
hätte in <a href={companyName}> aktiv werden können.

Fix: stripHtml ersetzt jene Schemata mit "blocked:" – legitimer
Text bleibt unangetastet, das Schema wird unschädlich.

LOW – documentPath ohne Validierung:
Bereits durch obigen Consent-Fix erledigt; Cleanup-Pass strippt
zusätzlich vorhandene dreckige Pfade.

cleanup-xss-and-mass-assignment.ts: neue cleanupConsents() läuft
beim Container-Start, normalisiert source per Whitelist auf
"unknown" + stripHtml über version/documentPath.

Live-verifiziert auf dev (alle drei Payloads geblockt + Cleanup
auf dirty DB greift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 01:13:19 +02:00
duffyduck bf7afdd9a6 Pentest KRITISCH: Backup-Restore braucht Confirm-Body
POST /api/settings/backup/:name/restore startete bei leerem Body
sofort den destruktiven Restore. Im Unterschied zu /factory-reset
fehlte der Magic-String-Confirm-Check, sodass ein versehentlicher
Re-Fire (Doppelklick, Browser-Tab-Replay, eingeloggter Admin auf
bösartiger Drittseite) die komplette DB stillschweigend
überschreiben konnte.

Fix: gleicher Defensive-Pattern wie factoryReset – Body muss
{ "confirm": "RESTORE-BESTAETIGT" } enthalten, sonst 400. Der
Magic-String ist absichtlich ein einzigartiges Token (kein Boolean),
damit kein Auto-JSON-Tooling/Replay aus Versehen triggern kann.

Frontend-API-Client setzt das Token im Body automatisch – der
existierende Bestätigungs-Dialog im UI bleibt UX-mäßig unverändert.

Live-verifiziert:
- leerer Body → 400
- { confirm: "ja" } → 400
- { confirm: "RESTORE-BESTAETIGT" } → 200, Restore läuft

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 01:05:00 +02:00
duffyduck b3a6620da6 XSS-Sanitization für AppSettings (companyName & Co)
Pentest-Befund (MEDIUM): companyName und weitere Plain-Text-Setting-
Keys nahmen via PUT /api/settings/:key XSS-Payloads wie
<img src=x onerror=alert(1)> ungefiltert entgegen. Nur Admin
triggerbar, aber E-Mail-Templates/PDF-Generatoren hätten den Wert
unescaped rendern können.

Fix in appSetting.service.ts: sanitizeSettingValue(key, value)
strippt HTML außer für die expliziten Editor-Keys (imprintHtml,
privacyPolicyHtml, authorizationTemplateHtml,
websitePrivacyPolicyHtml). Greift in updateSetting + updateSettings.

cleanup-xss-and-mass-assignment.ts bereinigt bestehende dreckige
Werte beim Container-Start (idempotent).

Live-verifiziert auf dev:
- PUT companyName="<img onerror=alert(1)>OpenCRM<script>alert(2)</script>"
  → DB: "OpenCRM"
- Bulk-PUT mit XSS auf companyName + defaultEmailDomain → gestrippt
- imprintHtml mit "<h1>...<p>" → unverändert (HTML-allowed)
- Cleanup-Skript auf dirty value: "EvilCo" statt mit Tags

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:49:19 +02:00
duffyduck 8ee5c9b07a Rollen+Permissions-Sync beim Container-Start
Folge-Fix für die DSGVO-Menü-Sache. Settings.tsx hatte ich auf
audit:read || gdpr:admin erweitert, aber auf bestehenden
Installationen läuft der prisma-Seed nicht (nur auf leeren DBs).
Wer das System früher installiert hat, hat die DSGVO-Rolle ohne
audit:read in der DB – das JWT enthielt die Perm dann nie, und der
neue Settings.tsx-Check blieb wirkungslos.

Neues Skript prisma/sync-roles.ts läuft idempotent bei jedem
Container-Start: upserts Permissions-Katalog + syncRolePermissions
für Admin, Developer, DSGVO, Mitarbeiter (R/W + R/O), Kunde.
Stammdaten, User und Verträge werden NICHT angefasst – sicher auf
prod.

Live-verifiziert: nach `DELETE audit:read FROM RolePermission`
liefert der nächste Lauf "+1 Permissions an Rolle #27", DSGVO ist
wieder komplett.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:41:39 +02:00
duffyduck 37df8c0c4a Backup-Operations-Log + EBUSY-Fix beim Restore
Backup-Seite zeigt zwei neue Log-Panels: links Backup-Erstellung,
rechts Backup-Wiederherstellung. Jeder Eintrag mit ✓/✗-Status,
Summary, Timestamp + User. Klick öffnet Modal mit vollständigem
Verlauf – alle console.log/error/warn/info-Zeilen werden während
der Operation in einen Puffer mitgefangen und im fullLog-Feld
persistiert. Auto-Refresh alle 5s.

Persistenz: neue Tabelle BackupLog mit Migration
20260519100000_backup_log (CREATE TABLE IF NOT EXISTS für Re-Deploys
auf DBs mit Vorab-db-push). fullLog auf 1 MB gecappt.

Endpoints (settings:update):
- GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
- GET /api/settings/backup-logs/:id

EBUSY-Fix: Der neue Log-Verlauf hat sofort einen alten Bug
sichtbar gemacht. backup.service.restoreBackup rief
deleteDirectory(UPLOADS_DIR) auf, dessen finales rmdirSync auf
/app/uploads ein EBUSY warf – das Verzeichnis ist im Container ein
Bind-Mount und lässt sich nicht aushängen. Fix: neuer Helper
emptyDirectory() löscht nur die Inhalte, das Verzeichnis bleibt
stehen.

Live-verifiziert: 4867 Datensätze + 1 Datei in 13.2s
wiederhergestellt; Log-Modal zeigt den vollständigen Verlauf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:53:04 +02:00
duffyduck 95541e8ac4 fix: DSGVO-Rolle sieht Menüpunkte in Einstellungen wieder
System-Block in Settings.tsx war komplett in
hasPermission('settings:update') gewickelt. DSGVO-User haben aber nur
audit:* und gdpr:* Perms – kein settings:update – und sahen damit
weder DSGVO-Dashboard, Datenschutzerklärung, Vollmacht-Vorlage,
Impressum, Website-Datenschutz, E-Mail-Versandlog noch Audit-Log.

Outer-Check auf (settings:update || audit:read || gdpr:admin)
erweitert. Innere Per-Card-Checks bleiben unverändert, sodass jeder
User nur das sieht, wofür er Perms hat.

Backend-API mit reinem DSGVO-Token gegengetestet: alle 9 Endpoints
liefern 200 – Routes hatten kein Permission-Problem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:44:48 +02:00
duffyduck 6ae815393e backup-restore: vollständiger Stack im Server-Log + lesbare UI-Details
Der globale ORM-Leak-Sanitizer ersetzt error/details, die TypeError/
"Cannot read properties of undefined" enthalten, durch "Operation
fehlgeschlagen". Das ist richtig für Auth-Endpoints, blockt aber bei
legitimen Admin-Operationen wie Restore die Diagnose-Info.

Backend (restoreBackup):
- console.error mit "[restore]"-Prefix loggt Backup-Name + vollen
  Stack ins Server-Log. Per `docker logs opencrm-app | tail -200`
  einsehbar.
- makeRestoreErrorReadable() strippt Stack-Frames, rephrased
  bekannte JS-Runtime-Marker ("TypeError:" → "Code-Fehler:",
  "Cannot read properties of undefined (reading 'x')" → "Wert
  fehlt: x") + cuttet auf 500 Zeichen. Dadurch passiert die
  Meldung den globalen Sanitizer und landet lesbar im Response.
- Response bekommt zusätzliches `hint`-Feld mit dem konkreten
  docker-Befehl.

Frontend (DatabaseBackup):
- extractError liefert jetzt strukturiertes Objekt
  {headline, details, hint} statt nur String.
- Dialog: Headline fett, details in Mono-Box, hint italic darunter.
- Toast: Headline + details zusammen, 10s sichtbar.

Live-verifiziert:
- Bad name → "Backup nicht gefunden" (klare Meldung)
- Echtes Backup → "4859 Datensätze wiederhergestellt" als Toast,
  Dialog zu

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:30:13 +02:00
duffyduck 06c427ee39 backup-restore: Toast bei Erfolg, ausführliche Fehlermeldung im Dialog
Vorher: nach Klick auf "Ja, wiederherstellen" passierte UI-seitig
einfach … nichts Sichtbares außer dass der Dialog (irgendwann) zuging.
Bei einem 500er-Fehler blieb der Dialog offen ohne erkennbare
Begründung – der User dachte, die Aktion sei nicht durchgelaufen,
und klickte teils nochmal.

Jetzt:
- Erfolg → Dialog zu, grüne Toast-Meldung mit der Backend-Response
  ("X Datensätze und Y Dateien wiederhergestellt"), 6s sichtbar.
- Fehler → Dialog bleibt offen mit roter Detail-Box drinnen,
  Backend-Felder error + details zusammengefügt, plus
  Toast-Notification 8s. Button-Label wird zu "Erneut versuchen",
  Sekundär-Button zu "Schließen".
- Beim Schließen wird mutation.reset() aufgerufen, damit beim
  nächsten Öffnen keine alten Fehler dranhängen.

extractError-Helper ist allgemein – kann später für andere
Backup-Aktionen wiederverwendet werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:20:16 +02:00
duffyduck 2cb6f172c9 Login-Rate-Limit pro (IP + Email)-Tupel + PUT /portal verbietet password
Login-Rate-Limit:
Bucket-Key jetzt `${ip}|${email-lowercase}`, ein Limiter (10/15min).
Vorher IP-only oder Email-only führten beide zu Problemen:
- IP-only: Proxy-Wechsel umgeht Sperre auf Account-Ebene
- Email-only: Familie hinter NAT (Max vertippt sich → Nina blockiert),
  Account-Lockout-DoS möglich
- Tupel: Max gesperrt, Nina von gleicher IP weiterhin frei, Max von
  anderer IP auch noch, eigener Account bleibt erreichbar.

Implementation:
- middleware/rateLimit.ts: keyGenerator → ip|email
- routes/auth.routes.ts: nur ein loginRateLimiter am /login + /customer-login
- controllers/rateLimitAdmin.controller.ts: Listing als (IP, Email)-
  Tupel, Reset nimmt ipAddress + optional email. Audit-resourceId =
  ip|email (gleich wie Bucket-Key) → Listing kann Reset herausfiltern.
- frontend/RateLimits.tsx: Tabelle mit IP- und Account-Spalte,
  Reset-Button schickt beides.

PUT /customers/:id/portal:
Body-Felder password/portalPassword/portalPasswordHash/
portalPasswordEncrypted werden explizit mit 400 abgelehnt. Vorher
wurden sie silent ignoriert + HTTP 200, was den Client glauben ließ,
das PW sei gesetzt. Hinweis im Error-Body zeigt auf den dedizierten
POST /portal/password-Endpoint.

Live-verifiziert:
- 11x falsch max@x.de → 429
- Nina/Admin von gleicher IP → durch
- Reset (IP, max) → max wieder 401 statt 429
- PUT /portal {password:"abcd"} → 400 "Felder nicht erlaubt"
- PUT /portal ohne password → 200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:18:59 +02:00
duffyduck 0f2dc44e45 cleanup: hacker@-Marker raus (legitime Nachnamen "Hacker")
Familie Hacker / Kunden mit "Hacker" als Nachnamen nutzen reichlich
hacker@familie-hacker.de & Co. Das `^hacker@`-Pattern hätte alle
fälschlich als Pentest-Marker erkannt. Raus damit.

Verbleibende Marker reichen aus:
- ^attacker@, ^pentest@, @evil.
- <script, onerror=, javascript:
- SQL-Injection-Pattern, Path-Traversal

Verifiziert: hacker@familie-hacker.de geht durch, attacker@evil.de
wird weiterhin erkannt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:50:36 +02:00
duffyduck b87a2a3d08 fix: Cleanup im echten Entrypoint + idempotente Migration
Beim Audit der Container-Pipeline zwei Bugs gefunden:

1) backend/docker-entrypoint.sh (= der wirklich ausgeführte Entrypoint
   laut Dockerfile) ruft jetzt das Cleanup-Script auf. Der Cleanup-
   Aufruf hing bisher fälschlich in docker/entrypoint.sh – ein
   alternatives Setup, das von der Standard-Compose-Konfiguration
   NICHT genutzt wird. Folge: das Cleanup ist auf prod nie gelaufen.

2) Migration 20260516173552_portal_password_must_change nutzt jetzt
   `ADD COLUMN IF NOT EXISTS`. Auf prod-DBs, die zwischen den Runden
   per `prisma db push` updated wurden (z.B. weil der erste Build
   mit `db push` provisioniert war), existiert die Spalte bereits.
   Ohne IF NOT EXISTS würde migrate deploy beim Hochziehen einer
   neueren Version mit "Duplicate column" abbrechen.

MariaDB ≥ 10.0.2 + MySQL ≥ 8.0.27 unterstützen IF NOT EXISTS für
ALTER TABLE ADD COLUMN – beides ist in unserer Compose-Konfig
abgedeckt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:24:33 +02:00
duffyduck 48fe69cdab Security-Hardening Runde 17: JWT-TTL + Pentest-Marker-Detection
Pentest Runde 17:

21.1 Access-Token TTL war 7 Tage statt 15min:
docker-compose.yml und .env.example standen schon richtig auf 15m
als Default. Die alten Beispiel-.env-Files (backend/.env.example,
docker/.env.example) hatten noch die alte Konvention "7d". Beide
auf 15m korrigiert + explizites JWT_REFRESH_EXPIRES_IN=7d ergänzt.
Auf prod muss die echte .env entsprechend angepasst werden.

17.5 Alte Pentest-Daten in DB:
Cleanup-Script erweitert um Pentest-Marker-Erkennung:
- Email-Pattern: ^hacker@, ^attacker@, ^pentest@, @evil\.
- XSS-Marker: <script, onerror=, javascript:
- Sonstige: SQL-Injection, Path-Traversal

Bewusst eng gefasst (Marker MUSS am Email-Anfang stehen), damit
legitime Kunden wie "stefanhacker@gmx.de" nicht als Pentest-Daten
durchgehen.

Default: nur warnen + Records auflisten. Opt-In via
CLEANUP_PURGE_PENTEST=true löscht die markierten Customer/User.

Live-verifiziert:
- stefanhacker@gmx.de (echt) → durchgelassen
- hacker@evil.de (Pentest) → erkannt + Warnung
- Mit Purge-Env → gelöscht

18.4 Klartext-Portal-PW-Abruf:
Bewusst drin gelassen (Admin-UI-Komfort). Endpoint ist mit
customers:update-Permission gated + Audit-Log (READ →
PortalPassword) – kein Bypass-Risiko, nur explizite Audit-Pflicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:06:03 +02:00
duffyduck 373fab8e83 Security-Hardening Runde 16: KRITISCH – Update-Responses sanitisieren
Pentest Runde 15:

20.3 KRITISCH:
PUT /customers/:id gab portalPasswordHash (bcrypt $2a$12$…) im
Response zurück. updateCustomer reichte das rohe Service-Output
ohne sanitize-Aufruf durch.

20.4 HOCH (gleiche Klasse):
PUT-Response leakte portalPasswordResetToken, portalPasswordMustChange,
consentHash, portalTokenInvalidatedAt.

Fix:
- updateCustomer + createCustomer rufen sanitizeCustomer bzw.
  sanitizeCustomerStrict je nach customers:update-Permission.
- updateContract + createContract + createFollowUp + createRenewal
  analog mit sanitizeContract / sanitizeContractStrict je nach
  isCustomerPortal.
- portalPasswordMustChange + portalTokenInvalidatedAt von
  PORTAL_HIDDEN_CUSTOMER_FIELDS zu SENSITIVE_CUSTOMER_FIELDS
  hochgezogen → greift auch in normaler sanitizeCustomer
  (Admin-Sicht).

Live-verifiziert:
- Admin PUT /customers/3 → 0 Leaks von Hash/Token/Expires/MustChange/
  consentHash/TokenInvalidatedAt; portalPasswordEncrypted bleibt
  für Admin sichtbar (UI-Workflow, separater Endpoint mit Audit)
- POST /customers → 0 Leaks
- Portal-User GET /customers/3 → 0 Leaks auch bei
  portalPasswordEncrypted/notes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:43:45 +02:00
duffyduck 5ffd1a4d2c fix: prisma.ts baut DATABASE_URL aus DB_*-Vars (für docker exec)
docker-compose reicht DB_USER/DB_PASSWORD/DB_HOST/DB_NAME an den
Container weiter, aber DATABASE_URL wird erst beim Container-Start
im entrypoint.sh aus diesen Komponenten zusammengebaut und exportiert.
`docker exec` startet eine neue Shell, die das exportierte
DATABASE_URL nicht erbt → ./scripts/admin-rescue.sh brach mit
"Environment variable not found: DATABASE_URL" ab.

src/lib/prisma.ts macht jetzt dieselbe URL-Konstruktion einmal
zentral. Damit funktionieren alle Wartungsskripte (reset-admin-
password, cleanup-xss-and-mass-assignment) bei docker exec ohne
Wrapper-Hack. Server-Start ist unbeeinflusst (DATABASE_URL ist da
schon gesetzt).

Live-verifiziert lokal: env -u DATABASE_URL DB_USER=... npx tsx
prisma/reset-admin-password.ts admin@admin.com → success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:15 +02:00
duffyduck 7b9a1981a7 fix: src/ ins Runtime-Image, damit prisma/*.ts-Wartungsskripte laufen
reset-admin-password.ts und cleanup-xss-and-mass-assignment.ts
importieren über '../src/lib/prisma.js' den shared Prisma-Client
+ Helper. Im Production-Container lag src/ aber nicht – nur dist/
für die Server-Runtime. Folge: admin-rescue.sh password ... brach
mit ERR_MODULE_NOT_FOUND ab.

Dockerfile kopiert jetzt zusätzlich src/ + tsconfig.json aus dem
backend-builder Stage ins Runtime-Image. tsx ist eh in dependencies
(nicht devDependencies) – läuft also auch unter --omit=dev. Server
selbst nutzt weiter dist/ via "node dist/index.js"; src/ ist nur
für ad-hoc Wartungsskripte da.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:53:52 +02:00
duffyduck 3dda83314a Admin-Rescue: PW-Reset direkt in DB + Rate-Limit-Reset
Use Case: Admin sperrt sich aus (admin@admin.com ist keine echte
Mailadresse, Passwort-vergessen-Flow kann keine Mail liefern) oder
Brute-Force-Lockout will sich nicht von selbst auflösen.

backend/prisma/reset-admin-password.ts:
- Findet User per Email, hasht neues PW mit bcrypt cost 12
- Schreibt direkt in user.password, setzt tokenInvalidatedAt=now()
  (kickt alle bestehenden Sessions), löscht Reset-Tokens
- Eigenes PW: Komplexitäts-Check 25 Zeichen
- Kein PW-Argument: 28-char Zufallspasswort (alle 4 Klassen
  garantiert), wird einmal in stdout ausgegeben

scripts/admin-rescue.sh:
- password <email> [pw]  → docker exec npx tsx … reset-admin-password
- unlock                  → docker restart opencrm-app (leert
                            In-Memory-Rate-Limit-Store)
- all <email> [pw]        → beides

Live-verifiziert: random-Modus, schwaches PW → klare Fehlerliste,
langes eigenes PW → akzeptiert, unbekannter User → exit 2, bash -n
syntax-check ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:47:02 +02:00
duffyduck 3e1fc3eab2 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)
Portal-Customer-Schwellwert bleibt 12 (Handy-Eingabe → längere PWs
erhöhen Reuse-Risiko). Mitarbeiter/Admin nutzen Passwort-Manager,
für die kostet die Länge nichts.

passwordGenerator.ts:
- STAFF_MIN_PASSWORD_LENGTH = 25, PORTAL_MIN_PASSWORD_LENGTH = 12
- validatePasswordComplexity({ minLength }) parametrisiert

Mitarbeiter-Pfade auf 25:
- createUser, register, setUserPassword
- confirmPasswordReset: Audience aus Token bestimmen
  (getPasswordResetAudience), User → 25, Customer → 12. Kein
  Body-Hint, damit kein Downgrade-Trick möglich.

Portal-Pfade unverändert (default 12):
- setPortalPassword, changeInitialPortalPassword

Seed-Admin:
- 28-char Zufallspasswort (statt 16) mit allen 4 Klassen garantiert
- SEED_ADMIN_PASSWORD-ENV nur akzeptiert wenn ≥ 25 Zeichen,
  sonst Log-Warnung + Random-Fallback

Frontend:
- UserList: Hinweis "Mind. 25 Zeichen". Update + PW gleichzeitig →
  zwei API-Calls (PUT + POST /users/:id/password) statt
  Password im Body durchzuschmuggeln (Backend strippt es eh)
- PasswordResetConfirm: Hinweis "Mind. 12 (Mitarbeiter: 25)"
- userApi.setPassword(id, password) neu

Live-verifiziert:
- POST /users/6/password "Hallo123!Test" (12) → 400 "mindestens 25"
- POST /users/6/password "MeinExtremLangesPW2026!Test" → 200,
  Login mit neuem PW → success
- POST /customers/3/portal/password "Hallo123!Test" (12) → 200
- POST /users createUser mit 12-char-PW → 400 "mindestens 25"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:19:58 +02:00
duffyduck cf8c6c84c2 Security-Hardening Runde 15: Pentest Runde 12 Folge-Fixes
M2-Reste – XSS-Strings + Mass-Assignment-Settings noch in DB:
Idempotentes Cleanup-Script prisma/cleanup-xss-and-mass-assignment.ts.
Strippt HTML aus Customer/User-String-Feldern, entfernt AppSettings
ohne Whitelist-Eintrag. Wird im entrypoint.sh nach Migrations + Seed
einmalig pro Container-Start ausgeführt.

User-Update + password-Feld:
password aus USER_UPDATABLE_FIELDS raus (CREATE behält es), neuer
dedizierter Endpoint POST /api/users/:id/password mit Audit-Log
"Passwort … durch Admin gesetzt" und Komplexitäts-Check.

JS-Runtime-Fehler-Leak:
ORM_LEAK_PATTERNS um TypeError/ReferenceError/SyntaxError/RangeError +
"Cannot read properties of undefined/null" + "is not a function/
defined" erweitert. Greift im globalen res.json()-Wrapper.

POST /contracts substring-Crash:
Controller validiert type/customerId, sonst 400. generateContractNumber
fängt nullish type ab (Fallback "CON").

Seed-Admin-Passwort:
Default "admin" verletzte 12-Zeichen-Policy. Jetzt 16-char
Zufallspasswort (alle 4 Klassen garantiert via Fisher-Yates) oder per
SEED_ADMIN_PASSWORD-ENV überschreibbar. BCRYPT-Cost 12 (war 10).
Passwort wird einmalig in stdout ausgegeben mit Warnung.

AppSettings-Whitelist: companyName + defaultEmailDomain ergänzt
(kamen aus seed.ts, in 1. Whitelist vergessen).

Live-verifiziert:
- POST /contracts {} → 400 "Vertrags-Typ erforderlich" (vorher
  TypeError-Stack)
- PUT /users/6 {password:"HackerPW2026!"} → 200 aber Login mit altem
  PW geht weiter
- POST /users/6/password mit "kurz" → 400 mit Komplexitäts-Fehlern
- Cleanup-Script: planted XSS bereinigt, hackerSetting+debugMode
  entfernt, idempotenter Re-Lauf

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:09:13 +02:00
duffyduck d545790a69 Security-Hardening Runde 14: Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip
Pentest Runde 11:

C2 KRITISCH – Factory Reset ohne Bestätigung:
Eingeloggter Admin konnte mit leerem oder beliebigem Body die DB
plätten (3× in einer Pentest-Session passiert). Server erzwingt jetzt
confirm:"FACTORY-RESET-BESTAETIGT" als String. Frontend-API sendet
den Wert automatisch mit.

M1 – Settings Mass Assignment:
PUT /api/settings akzeptierte beliebige Keys (superAdminEmail,
debugMode, allowedOrigins). Neue Whitelist ALLOWED_SETTING_KEYS in
appSetting.service.ts; updateSetting + updateSettings prüfen jeden
Key, unbekannte → 400.

M3 – Prisma-Error-Leak:
Statt 30+ Controller einzeln zu fixen, globaler res.json()-Wrapper
unter /api: error/details-Strings werden durch Pattern-Filter
geschickt, der ORM-/Stack-Trace-Muster zu "Operation fehlgeschlagen"
ersetzt. Original bleibt im Server-Log.

M2 – Stored XSS in Customer/User-Strings:
Neuer stripHtml()-Helper. pickCustomerUpdate/Create + pickUserUpdate/
Create rufen ihn auf jeden String-Wert. Defense-in-Depth gegen PDF/
E-Mail-Template-XSS-Vektoren – React-Frontend ist eh auto-escaped.

Live-verifiziert:
- factory-reset {} / {confirm:true} / {confirm:false} → 400, DB ok
- PUT /settings {superAdminEmail,...} → 400 + Keys aufgezählt;
  PUT /settings {customerSupportTicketsEnabled:"true"} → 200
- PUT /users/99999 → "Operation fehlgeschlagen" (vorher Prisma-Stack)
- PUT /customers/3 {companyName:"<script>...</script>EvilCorp"} →
  gespeichert als "EvilCorp"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 05:23:12 +02:00
duffyduck ef238b0145 Security-Hardening Runde 13: Live-Vollmacht-Konsistenz + embedded DTOs
Pentest Runde 10:

MEDIUM – Stale Token nach Vollmacht-Widerruf:
Selbst ein frischer Portal-Login lieferte JWT mit representedCustomer-
Ids/representedCustomers, obwohl die Vollmacht widerrufen war. Live-
Check beim Datenzugriff fing das ab (403), aber die UI zeigte weiter
„kann vertreten". customerLogin und getCustomerPortalUser (= /me +
Refresh) filtern representingFor jetzt zusätzlich über
getAuthorizedCustomerIds() – nur Beziehungen mit isGranted=true
landen im Token.

MEDIUM – DTO-Leak in embedded Objekten:
GET /customers/:id lieferte contracts[] mit commission/notes/
portalPasswordEncrypted/nextReviewDate; embedded customer in
/contracts/:id zeigte notes. sanitizeCustomer(Strict) ruft jetzt
sanitizeContract(Strict) auf jedes Element von contracts[] auf;
`notes` ist als PORTAL_HIDDEN_CUSTOMER_FIELDS aufgenommen.

LOW – /tasks?customerId=X gibt 200 mit leerem Array statt 403:
Konsistenz-Fix: wenn Portal-User explizit nach customerId filtert,
die er nicht vertreten darf → 403.

Live-verifiziert:
- Customer 1 vertritt 2+3 (Vollmachten widerrufen) → JWT
  representedCustomerIds=[], /me dito
- Portal /customers/1.contracts[0]: keine Leaks; Admin sieht weiter
  commission/notes; portalPasswordEncrypted generell weg
- Portal /tasks?customerId=2 → 403; /tasks?customerId=1 → 200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:47:20 +02:00
duffyduck 7b6b586033 fix: PortalPrivacy weiße Seite – Hooks-Reihenfolge nach early-return
useState + useEffect für den Download-Token standen nach dem
`if (isLoading) return <Laden />` early-return. Beim ersten Render
gab es 2 Hooks, beim zweiten 4 → React-Hook-Order-Mismatch → Crash →
weiße Seite. Mein Fehler aus der Download-Token-Migration (Runde 11).

Hooks vor den early-return verschoben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:33:40 +02:00
duffyduck 3dea381983 fix: UUID-Hashes (Datenschutz-Link) nicht als truncated-ID blocken
Der Integer-Truncation-Filter aus Runde 12 war zu breit: er hat jedes
Pfad-Segment geblockt, das mit einer Ziffer beginnt und nicht aus
reinen Ziffern besteht – also auch UUIDs wie
"3018c9b9-b337-4c9a-a402-b47872f8ddae". Damit warf der Datenschutz-
Link beim ersten Aufruf "Ungültiger Link" (in Wahrheit 400 vom Filter).

Engere Heuristik: ^\d+[a-zA-Z]+$ – reine Ziffern gefolgt von reinen
Buchstaben (`6abc`, `12foo`). UUIDs haben Bindestriche und Hex-
Buchstaben gemischt, werden korrekt durchgelassen.

Live-verifiziert: Datenschutz-Link mit UUID → 200; /customers/6abc →
weiterhin 400; /customers/3 → 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:21:52 +02:00
duffyduck 28c91759df Security-Hardening Runde 12: Information-Disclosure + Input-Validation
Pentest Runde 7 (Anschlussrunde):

MEDIUM – Interne Felder in Portal-Responses:
- sanitizeCustomerStrict strippt zusätzlich portalTokenInvalidatedAt,
  portalLastLogin, portalPasswordMustChange, lastBirthdayGreetingYear,
  privacyPolicyPath, businessRegistrationPath, commercialRegisterPath.
- Neue sanitizeContract/Strict + sanitizeContracts/Strict: entfernt
  portalPasswordEncrypted immer (nur über /password-Endpoint mit Audit
  abrufbar), für Portal-User zusätzlich commission/notes/nextReviewDate.
- getContract + getContracts wählen je nach isCustomerPortal die
  passende Variante. Mitarbeiter sehen commission/notes weiterhin.

LOW – Integer-Truncation bei IDs:
parseInt('6abc') → 6 lief vorher durch. Neue Heuristik-Middleware
unter /api: jedes Pfad-Segment, das mit Ziffer beginnt aber nicht
aus reinen Ziffern besteht, wird mit 400 abgelehnt. Trifft alle
Sub-Router ohne dass jede Route einzeln angefasst werden muss.

INFO – Rate-Limit: Code-Stand limit=10 für Login, limit=5 für
Password-Reset (lokal verifiziert: 11. failed login = 429). Pentester
sah vermutlich noch älteren Build. Kein Code-Change.

Live-verifiziert:
- /customers/6abc → 400 "Ungültige ID im URL-Pfad"
- /customers/3 → 200, /contracts/1abc/history → 400, normale Pfade OK
- Portal-User /customers/3: keine portalLastLogin/portalPasswordMustChange/
  portalTokenInvalidatedAt/etc. mehr in Response
- Portal-User /contracts/15: keine commission/notes/portalPasswordEncrypted/
  nextReviewDate
- Admin /contracts/15: commission/notes/nextReviewDate sichtbar,
  portalPasswordEncrypted weg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 08:51:52 +02:00
duffyduck c744eebfa3 Rate-Limit-Liste: bereits freigegebene IPs ausblenden
Die Liste basiert auf unveränderlichen SecurityEvents – ein Reset
leerte nur den In-Memory-Limiter, aber die historischen Events
blieben weitere 15 Min in der Anzeige stehen ("Freigeben klappt nicht").

Fix: für jede candidate-IP wird der letzte AuditLog-Eintrag
(resourceType=RateLimit) im 15-Min-Fenster geprüft. Liegt er nach dem
letzten Hit der IP, fliegt die IP aus der Liste – aber sobald wieder
ein RATE_LIMIT_HIT nach dem Reset kommt, taucht die IP wieder auf.

Live-verifiziert: trigger → 1 Eintrag; reset → 0 Einträge;
erneuter trigger → 1 Eintrag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:26:12 +02:00
duffyduck 956bc394b8 Rate-Limit-Sperren: Admin-UI zum Freigeben
Bei zu vielen Login-Fehlversuchen war ohne Container-Restart kein Weg
zurück. Jetzt sehen Admins die aktiven Sperren und können einzeln
freigeben.

Backend:
- GET  /api/settings/rate-limits/active (settings:read)
  Liest SecurityEvent RATE_LIMIT_HIT der letzten 15 Min, gruppiert nach
  IP, liefert lastEmail/limiters/hitCount/lastHit.
- POST /api/settings/rate-limits/reset (settings:update)
  Body { ipAddress } → ruft loginRateLimiter.resetKey + passwordReset-
  RateLimiter.resetKey auf (express-rate-limit v7), audited als
  UPDATE auf resourceType=RateLimit.

Frontend:
- Neue Seite /settings/rate-limits: Tabelle mit IP/Email/Limiter/Hits/
  Letzter-Hit/Aktion. Auto-Refresh alle 15s. Freigeben-Button pro IP.
- Kachel in Settings-Übersicht (orange, ShieldOff-Icon, settings:read).

Live-verifiziert: 11 failed Logins → 429 ab dem 11.; Liste zeigt
IP + Email; POST /reset → 200; danach wieder 401 statt 429; Audit-Log
„Rate-Limit für IP 127.0.0.1 manuell freigegeben" angelegt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:20:43 +02:00
duffyduck 69b9a35674 Security-Hardening Runde 11: Pentest Runde 7 (Portal-PW + Download-Tokens)
Hit-List vom Pentester abgearbeitet. Hauptpunkte:

1) Contract/Mail-Credentials (password/internet/sip/simcard, mailbox/send/
   reset-password): ALLE bereits durch canAccess* gesichert, keine Lücke.

2) GET /customers/:id/portal/password (Klartext-Portal-PW-Abruf):
   fehlender canAccessCustomer-Check ergänzt. Defense in depth gegen
   versehentliche customers:update-Permission an Portal/eingeschränkte
   Mitarbeiter.

3) Admin-Endpoints (factory-reset, developer/*, audit-logs/rehash,
   audit-logs/customer): durch admin-Permissions geschützt – Portal-User
   haben diese nicht.

4) Token-in-URL (NIEDRIG): Langlebige Access-JWTs landeten als ?token= in
   URLs für iframe-PDFs, Audit-Export-Window etc. → nginx-Logs +
   Browser-History + Referer.
   Lösung: kurzlebige Download-Tokens.
   - signDownloadToken() liefert JWT mit type='download', exp=60s
   - Auth-Middleware akzeptiert type='download' AUSSCHLIESSLICH via
     ?token=, niemals als Bearer-Header
   - POST /api/auth/download-token Endpoint (authenticated)
   - Frontend: authApi.getDownloadToken() utility
   - 4 Stellen migriert: AuditLog-Export, PdfTemplate-Preview-iframe,
     PdfTemplate-Generate, ContractDetail-PDF-Generate (2x),
     Portal-Privacy-PDF
   - fileUrl/getAttachmentUrl sind synchron + breit gestreut – Migration
     bleibt für Folge-PR

Live-verifiziert:
- Download-Token: 1773 Zeichen, type=download, exp-iat=60s
- als Header → 401 (Falscher Token-Typ), als ?token= → 200
- portal-user (Customer 3) auf customers/2/portal/password → 403

Rate-Limiter-Check: express-rate-limit Fixed-Window, kein Reset bei jedem
Request (Pentester-Klage „Fenster reseted sich" stimmt mit dem Code nicht
überein – wahrscheinlich Retry-After-Misinterpretation). Kein Code-Bug
identifiziert; ggf. später Admin-Override-Endpoint nachrüsten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:40:00 +02:00
duffyduck a982795388 Security-Hardening Runde 10: Pentest Runde 6 (8 Findings + struktureller Audit-Sweep)
KRITISCH:
- emails/:id/thread bekommt canAccessCachedEmail
- customers/:customerId/representatives/search bekommt canAccessCustomer
  (Buchstaben-Brute-Force konnte sonst die Kunden-DB enumerieren)

HOCH:
- birthdays/upcoming: Portal-User → 403 (Name/E-Mail/Telefon/Geb-Datum
  aller Kunden leakte)
- contracts/:id/history (GET/POST/PUT/DELETE) bekommt canAccessContract
- mailbox-accounts / unread-count / contracts/:id/emails/folder-counts
  bekommen canAccessCustomer bzw. canAccessContract
- Vertreter-Vollmacht-Check ist jetzt live: neuer Helper
  getPortalAllowedCustomerIds() in accessControl.ts ruft
  hasAuthorization() für jedes vertretene Customer ab. Eingesetzt in
  getTasks/createSupportTicket/createCustomerReply/getAllTasks/
  getTaskStats und updateCustomerConsent. Widerrufene Vollmachten
  haben jetzt SOFORT keinen Zugriff mehr (vorher: bis JWT abläuft).

MITTEL:
- confirmPasswordReset speichert portalPasswordEncrypted nicht mehr
  beim Self-Service-Reset (war nur für Admin-OTPs gedacht); +
  portalPasswordMustChange=false explizit
- getCustomers pagination total reflektiert jetzt nur erlaubte IDs
  (über DB-Filter in customerService.getAllCustomers)

Audit-Sweep (defense in depth, falls Rolle versehentlich Update-
Permissions bekommt):
- 16 cachedEmail-Operationen (markAsRead, toggleStar, assign/unassign,
  save-as-pdf/invoice/contract-document, save-to, attachment-targets,
  trash-ops)
- 4 contract-Operationen (createFollowUp, createRenewal, snoozeContract,
  removeContractMeter)
- 12 sub-CRUD-Operationen (address/bankcard/document/meter
  update+delete, meter-reading add/update/delete/transfer)
- 2 representative-Operationen (add/remove)

Live-verifiziert: Portal-Customer-3 auf alle fremden IDs → 403,
Admin sieht alles, eigene Ressourcen weiterhin 200, Customer 1 mit
widerrufener Vollmacht für Customer 3 → 0 fremde Verträge in der
Response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:47:17 +02:00
duffyduck 38c2d82c02 Security-Hardening Runde 9: Pentest Runde 5
KRITISCH – change-initial-portal-password ohne mustChange-Pflicht-Check:
Jeder Portal-User konnte jederzeit sein Passwort ohne Kenntnis des
alten ersetzen (XSS-/Token-Hijack-Eskalation). Endpoint war NUR für
den OTP-Erst-Login gedacht, prüfte aber das Flag nicht. Fix: Customer
laden, portalPasswordMustChange=true erzwingen, sonst 403.

NIEDRIG – consentHash leakte über GET /customers/🆔
Hash ist Pseudo-Credential für den öffentlichen Consent-Link. Jetzt
in SENSITIVE_CUSTOMER_FIELDS (sanitize.ts) → wird aus jeder customer-
Response gestrippt. Wer ihn legitim braucht, holt ihn über
/gdpr/customer/:id/consent-status.

NIEDRIG – Public consent-grant Response leakte CustomerConsent-Records:
POST /api/public/consent/:hash/grant gab volle Records inkl. ipAddress
und createdBy (Kunden-Name) zurück. Auf { granted: <count> } reduziert
– Frontend liest eh nur success.

Live-verifiziert:
- Change-Initial ohne Flag → 403; mit Flag → 200; danach Flag=false →
  erneuter Aufruf 403
- GET /customers/3 → consentHash null, portalPasswordHash null
- /gdpr/customer/3/consent-status → consentHash weiterhin sichtbar
- Public-Grant-Response: {granted: 4}, keine ipAddress/createdBy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:57:09 +02:00
duffyduck 75c833500e Security-Hardening Runde 8: Cockpit-IDOR (Portal sah ALLE Kunden)
Pentest Runde 4 – HOCH:
GET /api/contracts/cockpit gab Portal-Usern mit contracts:read
die kompletten Vertrags-, Ausweis- und Zählerstand-Daten ALLER
Kunden zurück. Realer Angriff erfolgreich durchgespielt.

Fix:
contractCockpitService.getCockpitData({ customerIds? }) – wenn
gesetzt, werden ALLE internen Queries (Contract, CustomerConsent
GRANTED/WITHDRAWN, IdentityDocument-Expiry, MeterReading-Reported)
auf diese Customer-IDs eingeschränkt.

Controller getCockpit ermittelt customerIds analog getContracts:
- isCustomerPortal → [eigene, ...vertretene mit Vollmacht]
- sonst (Mitarbeiter/Admin) → undefined (alle Kunden)

Live-verifiziert:
- Admin: 17 Verträge über 3 Kunden (Baseline)
- Portal-User Customer 1: 12 Verträge, alle mit customerId=1
- Portal-User Customer 3: 3 Verträge, alle mit customerId=3
- 0 fremde Verträge in Portal-Responses

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:55:38 +02:00
duffyduck a7d12b8540 Security-Hardening Runde 7: Pentest Runde 3 (3 Findings)
KRITISCH – Privilege Escalation:
POST /api/developer/setup war ohne Auth erreichbar und konnte
developer:access der Admin-Rolle hinzufügen → volle DB-Kontrolle
via /developer/*-Routen. Endpoint ersatzlos entfernt; manuelles
Setzen geht über prisma/add-developer-permission.ts (CLI).

HOCH – Fehlende Migration auf Prod:
portalPasswordMustChange war im Code, aber prod-DB hatte die
Spalte nicht → jeder Kunden-Login warf Prisma-Schema-Error → DoS.
Root Cause: db push statt migrate dev während Entwicklung →
kein Migration-File im Repo. Fix: handgenerierte Migration
20260516173552_portal_password_must_change/migration.sql, lokal
mit migrate resolve --applied registriert, durch shadow-DB-Reset
verifiziert. entrypoint.sh führt migrate deploy bereits aus.

MITTEL – Prisma-Internals-Leak im Login-Error:
error.message wurde 1:1 an den Client gegeben → bei DB-Schema-
Fehlern leakten Tabellen- und Spaltennamen. Whitelist-Filter
safeLoginError() in auth.controller.ts: nur 'Ungültige
Anmeldedaten' und 'E-Mail und Passwort erforderlich' werden
durchgereicht, alles andere wird zu generischem 'Anmeldung
fehlgeschlagen' maskiert. Original landet im Server-Log.

Live-verifiziert:
- POST /api/developer/setup → HTTP 404
- Falsches Customer-PW → 'Ungültige Anmeldedaten' (keine Internals)
- Spalte testweise gedropped → 'Anmeldung fehlgeschlagen' (generisch),
  Original-Message nur im Server-Log
- Shadow-DB-Reset + migrate deploy → Spalte korrekt erzeugt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:39:02 +02:00
duffyduck 8534be22d0 Einmalpasswort-Flow für Portal-Credentials
Wenn Admin "Zugangsdaten versenden" klickt, ist das Passwort jetzt ein
echtes Einmalpasswort: beim ersten erfolgreichen Portal-Login werden
Hash + Encrypted-Feld sofort genullt und der Kunde wird zwangsweise
auf eine "Neues Passwort vergeben"-Seite geleitet. Erst nach eigenem
Passwort kommt er ins Portal.

Schema:
- Customer.portalPasswordMustChange: Boolean @default(false)

Backend:
- sendPortalCredentials setzt Flag = true + erweitertes Mail-Template
  mit Einmalpasswort-Warnung
- customerLogin: bei Flag=true wird OTP konsumiert (Hash+Encrypted=null,
  portalLastLogin aktualisiert), Response enthält mustChangePassword=true
  in token-payload + user-objekt
- setCustomerPortalPassword (manuelles Setzen) räumt Flag wieder auf
- changeInitialPortalPassword: neue Service-Funktion + Endpoint
  POST /api/auth/change-initial-portal-password (authenticated, nur
  Portal-User), validiert Komplexität, setzt neuen Hash, löscht
  Encrypted, invalidiert Session via portalTokenInvalidatedAt

Frontend:
- User-Type erweitert um mustChangePassword
- AuthContext.customerLogin gibt User zurück (für sofortige Routing-
  Entscheidung)
- Login.tsx: redirect zu /change-initial-password wenn mustChangePassword
- ProtectedRoute: zwingt eingeloggte User mit Flag immer zur Change-Seite
- ChangeInitialPasswordGate: blockt User OHNE Flag vom Zugriff
- ChangeInitialPassword: eigene Seite mit Live-Komplexitäts-Hint,
  Passwort-Wiederholung, automatischer Logout + Redirect nach Erfolg

Live-verifiziert (10 Schritte):
- Setzen → Send → DB-Flag=true → OTP-Login gibt mustChange=true und
  consumed Hash → Re-Login mit OTP fehlschlägt → Change schwach=400,
  komplex=200 → neues Passwort funktioniert → Session invalidated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:48:13 +02:00
duffyduck f0c97cd46d todo.md: Passwort-Komplexität + Real-IP-Fix dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:26:52 +02:00
duffyduck 8a5ffbb563 Passwort-Komplexität + Portal-Credentials-UX
validatePasswordComplexity (12 Zeichen, Groß/Klein/Zahl/Sonderzeichen)
zentral in passwordGenerator.ts; jetzt erzwungen in setPortalPassword,
confirmPasswordReset, register, createUser, updateUser.

Neue Endpoints:
- POST /customers/:id/portal/password/generate → 16-Zeichen Zufallspasswort
- POST /customers/:id/portal/send-credentials → Versand per Mail
  (nur wenn portalEnabled aktiv)

Frontend (CustomerDetail): Generate-Button vor Setzen, Send-Credentials
nach gesetztem Passwort, Live-Komplexitäts-Hint (✓/○) während Eingabe,
alert() durch Toast-Notifications ersetzt.

Live-verifiziert: schwaches Passwort → 400 mit Detail-Fehler, komplexes
Passwort → 200, Generator liefert 16-Zeichen-Passwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:26:11 +02:00
duffyduck 6af1a4bbd4 fix(security): trust proxy = 1 bei HTTPS_ENABLED – echte Client-IP statt Proxy-IP
Wenn der TLS-Reverse-Proxy (Nginx Proxy Manager) auf einer SEPARATEN Box
läuft, kommt nicht von 127.0.0.1 → `trust proxy = 'loopback'` greift
nicht → req.ip bleibt die NPM-IP statt der echten Client-IP. Folgen:

- Rate-Limiter sieht alle Angriffe als von "einem" Client (= NPM)
- Security-Monitor loggt Proxy-IP statt Angreifer-IP (Beweis im
  Audit-Log: "ACCESS_DENIED ... 172.0.2.12" für alle Versuche)
- IDOR-Threshold-Detection (>5 in 5 min pro IP) triggert auf der NPM-IP
  und blockt damit alle legitimen User durch denselben Proxy

Fix: bei HTTPS_ENABLED=true `trust proxy = 1` (vertraue genau einem Hop –
den vorgelagerten TLS-Proxy). Bei HTTPS_ENABLED=false bleibt es bei
`loopback` (keine Proxy-Annahme bei direkter http://ip:port-Nutzung).

Voraussetzung für HTTPS_ENABLED=true: Backend ist nicht direkt aus
dem Internet erreichbar, sonst könnte ein direkter Connect ein
X-Forwarded-For faken und den Limiter umgehen. Bei NPM-Setup
gewährleistet durch Docker-Network + nicht-veröffentlichten
Backend-Port.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:24:21 +02:00
duffyduck 92d2e62e79 security: portalPasswordHash + Encrypted aus embedded customer in /contracts/* entfernen
Folgefix zum CRITICAL-IDOR auf Stressfrei-Sub-Routes: der separate
/customers/:id-Endpoint sanitizt seinen Output schon, aber GET /contracts/:id
embeddete weiterhin das volle Customer-Objekt inkl.
- portalPasswordHash (bcrypt-Hash des Portal-Login-Passworts)
- portalPasswordEncrypted (AES-256-GCM des Klartext-Passworts)
- portalPasswordResetToken (langlebiger 1-time-Token)

Zwei Lecks im contract.service:
- getContractById hatte `customer: true` ohne Sanitize
- createContract hatte dasselbe Muster

Beide jetzt mit sanitizeCustomerStrict() nach dem Load. Der Helper war schon
im utils/sanitize.ts vorhanden – wurde nur nicht aufgerufen.

Live-verifiziert: GET /api/contracts/1 → embedded customer enthält 30 saubere
Felder, KEIN portalPasswordHash/Encrypted/ResetToken mehr.

Weitere `customer: true`-Stellen geprüft und freigegeben:
- pdfTemplate.service.generateFilledPdf: nur internal, gibt PDF-Buffer zurück
- cachedEmail.controller.saveEmailAsPdf: nur internal für File-Ops
- getAllContracts: schon mit explizitem Select (5 sichere Felder)
- updateContract: kein customer-Include

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:06:01 +02:00
134 changed files with 13626 additions and 968 deletions
+20 -2
View File
@@ -66,6 +66,16 @@ LISTEN_ADDR=0.0.0.0 # In Docker = 0.0.0.0, in Bare-Metal-Production = 127
# (ERR_SSL_PROTOCOL_ERROR auf den Assets).
HTTPS_ENABLED=false
# SSRF-Schutz: private IP-Ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
# 192.168.0.0/16, ::1, fc00::/7, localhost) bei Provider/SMTP-Test-Connection
# blockieren. Default `false` damit On-Prem-Setups Plesk/Dovecot/Postfix auf
# 127.0.0.1 oder im internen Netz nutzen können. Für Cloud-Deployments
# (öffentlich erreichbares Backend) auf `true` setzen, sonst kann ein
# eingeloggter Admin via /email-providers/test-connection interne Services
# anpingen. Cloud-Metadata-Endpoints (169.254.169.254 etc.) sind UNABHÄNGIG
# vom Flag immer geblockt.
SSRF_BLOCK_PRIVATE_IPS=false
# ============== ADMINER (DB-UI) ==============
# Theme-Auswahl. Verfügbare Designs im offiziellen adminer:latest Image:
# adminer-dark, brade, bueltge, dracula, esterka, flat, galkaev,
@@ -76,9 +86,17 @@ HTTPS_ENABLED=false
ADMINER_DESIGN=dracula
# ============== SEED ==============
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com / admin
# + Stammdaten an) nichts zu konfigurieren.
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com +
# Stammdaten an) nichts zu konfigurieren.
# Nur wenn man eine NICHT-leere DB nochmal forciert seeden will (z.B. nach
# Reset / Stammdaten-Update), kurz auf 'true' setzen, neu starten, dann
# wieder zurück.
RUN_SEED=false
# Initial-Passwort für admin@admin.com beim Seed. Mindestens 25 Zeichen,
# sonst wird der Wert ignoriert und stattdessen ein 28-Zeichen-Zufalls-
# passwort erzeugt und EINMAL nach stdout geschrieben:
# docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
# Wer keine Lust auf die Log-Suche hat, setzt hier ein eigenes starkes
# Passwort vor dem ersten `docker compose up -d`.
# SEED_ADMIN_PASSWORD=
+78 -14
View File
@@ -10,7 +10,9 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Adressen**: Mehrere Liefer-/Melde- und Rechnungsadressen pro Kunde
- **Bankkarten**: Mit Ablaufdatum, Aktiv-Status und Dokument-Upload (PDF)
- **Ausweise**: Personalausweis, Reisepass, etc. mit Ablaufdatum und Dokument-Upload (PDF)
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie,
Pflicht-Zuordnung zur Lieferadresse und Folgezähler-Kette
(Auto-Propagation auf alle Verträge mit dem Vorgänger)
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT``ACTIVE` (mit Vertragsbeginn),
@@ -61,7 +63,7 @@ docker compose up -d
```
Browser:
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` / `admin`)
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` Initial-Passwort siehe unten)
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
Alle persistenten Daten liegen in `./data/`:
@@ -75,9 +77,22 @@ Alle persistenten Daten liegen in `./data/`:
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
> **Erste Inbetriebnahme:** In der `.env` einmalig `RUN_SEED=true` setzen,
> `docker compose up -d` ausführen, dann wieder auf `false`. Danach existiert
> der initiale Admin-User `admin@admin.com` / `admin`.
> **Erste Inbetriebnahme:** Bei leerer DB läuft der Seed automatisch
> (kein Eingriff nötig). Nur wenn du eine bereits befüllte DB erneut
> seeden willst, einmalig `RUN_SEED=true` in der `.env` setzen,
> `docker compose up -d` ausführen, dann wieder auf `false`.
>
> Der initiale Admin-User ist **`admin@admin.com`**. Das Passwort wird
> beim Seed zufällig generiert (28 Zeichen) und genau einmal in den
> Container-Logs ausgegeben:
> ```bash
> docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
> ```
> Alternativ kannst du **vor dem ersten Start** ein eigenes Passwort
> setzen, indem du `SEED_ADMIN_PASSWORD=…` (mindestens 25 Zeichen, sonst
> wird's ignoriert) in der `.env` einträgst. Nach dem ersten Login
> sollte das Passwort über *Einstellungen → Mein Profil* geändert
> werden.
## Voraussetzungen
@@ -186,11 +201,33 @@ Das Frontend läuft auf `http://localhost:5173`
Nach dem Seed sind folgende Zugangsdaten verfügbar:
- **E-Mail:** admin@admin.com
- **Passwort:** admin
- **E-Mail:** `admin@admin.com`
- **Passwort:** wird beim Seed zufällig generiert (28 Zeichen) und einmal
in den Container-Logs ausgegeben:
```bash
docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
```
Beispiel-Ausgabe:
```
========================================================
Admin-User: admin@admin.com
Initial-Passwort: <28-zeichen-zufalls-passwort>
⚠️ Dieses Passwort wird hier EINMAL ausgegeben!
Bitte sofort nach dem ersten Login ändern.
========================================================
```
Wer das Log nicht mehr findet (z.B. Container schon länger gerollt),
setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` in der `.env` und startet
einmalig mit `RUN_SEED=true` neu der Seed ist idempotent und
überschreibt das Admin-Passwort. Danach `RUN_SEED` wieder auf `false`.
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
> ändern und Secrets rotieren siehe [Production-Deployment](#production-deployment).
> **Tipp:** Wer von Anfang an ein eigenes Admin-Passwort haben möchte,
> setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` **bevor** der erste
> `docker compose up -d` läuft. Der Wert wird dann statt des Zufalls-
> passworts verwendet (siehe `.env.example`).
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren
> siehe [Production-Deployment](#production-deployment).
## Production-Deployment
@@ -210,6 +247,25 @@ LISTEN_ADDR=127.0.0.1
CORS_ORIGINS=https://crm.deine-domain.de
```
### Deployment-Modus: On-Prem vs. Cloud
OpenCRM ist primär als **On-Prem-Anwendung** designed (eigener Server / VM,
hinter Reverse-Proxy). Für **Cloud-Deployments** (öffentlich erreichbares
Backend, Shared-Infrastructure, Hyperscaler) gibt es einen zusätzlichen
SSRF-Schalter:
```env
# Cloud-Deploy: zusätzlich alle privaten IP-Ranges für Provider-/SMTP-
# Test-Connection blockieren (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
# 192.168.0.0/16, ::1, fc00::/7, localhost). Default false, weil
# On-Prem-Setups oft Plesk/Dovecot auf 127.0.0.1 brauchen.
SSRF_BLOCK_PRIVATE_IPS=true
```
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
sind UNABHÄNGIG vom Flag **immer** geblockt das ist Mindestschutz gegen
AWS/GCP/Azure-IMDS-Diebstahl.
Plus:
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
@@ -221,7 +277,9 @@ Plus:
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
auf derselben Domain via Proxy-Path.
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
- **Admin-Initial-Passwort ändern**: das beim Seed generierte Zufalls-Passwort
(siehe [Erster Login](#erster-login)) im Profil überschreiben oder gleich
per `SEED_ADMIN_PASSWORD` in der `.env` vor dem ersten Start setzen.
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
durchklicken.
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
@@ -668,11 +726,15 @@ Je nach Vertragstyp werden unterschiedliche Felder im Formular angezeigt:
#### Strom & Gas (ELECTRICITY, GAS)
- Zähler-Auswahl
- Jahresverbrauch (kWh/m³)
- Grundpreis, Arbeitspreis
- Bonus
- Zähler-Auswahl (gefiltert auf die Lieferadresse des Vertrags)
- Jahresverbrauch (kWh/m³) bei Folgeverträgen mit Schätzwert
aus dem Vorvertrag und 1-Klick-Übernahme
- Grundpreis, Arbeitspreis (HT/NT bei Zweitarif)
- Sofort-Bonus, Neukunden-Bonus
- Vorversorger, Kundennummer beim Vorversorger
- Folgezähler-Workflow (Zählerwechsel): Wechseldatum + optionaler
Endstand des alten Zählers, der automatisch als Zählerstand
erfasst wird und in die Verbrauchsberechnung einfließt
#### Internet (DSL, CABLE, FIBER)
@@ -696,6 +758,8 @@ Je nach Vertragstyp werden unterschiedliche Felder im Formular angezeigt:
- Rufnummer, SIM-Kartennummer (ICCID)
- PIN, PUK (verschlüsselt)
- Multisim-Flag, Hauptkarte-Flag
- Kartennutzer (optional) tatsächlicher Nutzer bei Firmen-/
Familienverträgen, kann vom Vertragsinhaber abweichen
> **Hinweis Multisim:** Nicht buchbar bei Klarmobil, Congstar, Otelo. Benötigt Freenet oder vergleichbar.
+3 -1
View File
@@ -9,7 +9,9 @@ DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
# JWT
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
JWT_EXPIRES_IN="7d"
# Access kurz (XSS-Schutz, nur JS-Memory). Refresh lang im httpOnly-Cookie.
JWT_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"
# Encryption (for portal credentials)
ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
+6
View File
@@ -46,6 +46,12 @@ COPY --from=backend-builder /build/backend/dist ./dist
COPY --from=backend-builder /build/backend/node_modules/.prisma ./node_modules/.prisma
COPY --from=backend-builder /build/backend/node_modules/@prisma ./node_modules/@prisma
COPY backend/prisma ./prisma
# src/ mitkopieren, damit prisma/*.ts-Wartungsskripte (cleanup, reset-admin-
# password etc.) auch im Production-Container via `npx tsx` laufen können
# die importieren über '../src/lib/prisma.js' den shared Prisma-Client.
# Server selbst läuft weiter aus dist/.
COPY --from=backend-builder /build/backend/src ./src
COPY backend/tsconfig.json ./tsconfig.json
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
COPY --from=frontend-builder /build/frontend/dist ./public
+25
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 "
@@ -122,5 +130,22 @@ if [ "$RAN_SEED" = "true" ] && [ -d /app/factory-defaults-builtin ] \
|| echo "[entrypoint] Factory-Defaults-Seed fehlgeschlagen ignoriert"
fi
# Permissions + Rollen-Sync: Stellt sicher, dass nachträglich hinzugefügte
# Permissions (z.B. audit:read auf der DSGVO-Rolle) auch auf bestehenden
# DBs ankommen. Seed läuft NICHT auf nicht-leeren DBs, daher würden alte
# Installationen sonst mit unvollständigen Role-Perms laufen. Idempotent,
# fasst keine Stammdaten / User / Verträge an.
echo "[entrypoint] Rollen + Permissions synchronisieren…"
npx tsx prisma/sync-roles.ts \
|| echo "[entrypoint] Role-Sync fehlgeschlagen nicht kritisch"
# Datenbereinigung: XSS-Strings aus Customer/User-Stringfeldern strippen,
# nicht-whitelisted AppSettings entfernen, Pentest-Marker melden (Default
# nur warnen; CLEANUP_PURGE_PENTEST=true löscht markierte Records).
# Idempotent läuft bei jedem Container-Start ohne Risiko.
echo "[entrypoint] Datenbereinigung läuft…"
npx tsx prisma/cleanup-xss-and-mass-assignment.ts \
|| echo "[entrypoint] Cleanup übersprungen / fehlgeschlagen nicht kritisch"
echo "[entrypoint] Starte Backend…"
exec "$@"
@@ -0,0 +1,343 @@
/**
* Einmal-Bereinigung für Pentest-Reste (Runde 12 / 2026-05-18):
*
* 1. HTML-Tags aus Customer/User-Stringfeldern strippen (M2-Stored-XSS-Reste)
* 2. Unbekannte App-Settings entfernen, die durch Mass-Assignment in die DB
* gerutscht sind, BEVOR die Whitelist eingezogen wurde (M1-Reste).
*
* Idempotent: wenn nichts zu tun ist, ändert sich nichts. Bei Bedarf
* mehrfach aufrufbar.
*/
import prisma from '../src/lib/prisma.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 = [
'salutation', 'firstName', 'lastName', 'companyName',
'birthPlace', 'email', 'phone', 'mobile',
'taxNumber', 'commercialRegisterNumber', 'notes',
];
const USER_STRING_FIELDS = [
'firstName', 'lastName', 'email',
'whatsappNumber', 'telegramUsername', 'signalNumber',
];
async function cleanupXss() {
const customers = await prisma.customer.findMany();
let touched = 0;
for (const c of customers) {
const updates: Record<string, string> = {};
for (const field of CUSTOMER_STRING_FIELDS) {
const v = (c as any)[field];
if (typeof v === 'string') {
const cleaned = stripHtml(v) as string;
if (cleaned !== v) updates[field] = cleaned;
}
}
if (Object.keys(updates).length > 0) {
console.log(` Customer #${c.id}: bereinigt:`, Object.keys(updates).join(', '));
await prisma.customer.update({ where: { id: c.id }, data: updates });
touched++;
}
}
console.log(` → Customer bereinigt: ${touched}`);
const users = await prisma.user.findMany();
let userTouched = 0;
for (const u of users) {
const updates: Record<string, string> = {};
for (const field of USER_STRING_FIELDS) {
const v = (u as any)[field];
if (typeof v === 'string') {
const cleaned = stripHtml(v) as string;
if (cleaned !== v) updates[field] = cleaned;
}
}
if (Object.keys(updates).length > 0) {
console.log(` User #${u.id}: bereinigt:`, Object.keys(updates).join(', '));
await prisma.user.update({ where: { id: u.id }, data: updates });
userTouched++;
}
}
console.log(` → User bereinigt: ${userTouched}`);
}
// HTML in Plain-Text-Settings strippen: WYSIWYG-Editoren liefern
// absichtlich HTML, alles andere (companyName, defaultEmailDomain, ...)
// muss reiner Text bleiben. Pentest 2026-05-19, MEDIUM.
const HTML_ALLOWED_SETTING_KEYS = new Set([
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
]);
function stripHtmlString(s: string): string {
return s
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/(?:javascript|data|vbscript)\s*:/gi, 'blocked:');
}
// Legitime CustomerConsent.source-Werte. Alles andere wird beim Cleanup
// auf 'unknown' normalisiert. Pentest 2026-05-20.
const ALLOWED_CONSENT_SOURCES = new Set([
'portal',
'public-link',
'telefon',
'papier',
'email',
'crm-backend',
]);
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
// 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).
// source: Whitelist erzwingen.
// documentPath zusätzlich gegen Pfad-Traversal absichern (27.1).
let versionStripped = 0;
let pathNulled = 0;
let sourceFixed = 0;
const consents = await prisma.customerConsent.findMany({
select: { id: true, source: true, documentPath: true, version: true },
});
for (const c of consents) {
const data: Record<string, string | null> = {};
if (c.version && c.version !== stripHtmlString(c.version)) {
data.version = stripHtmlString(c.version);
versionStripped++;
}
if (c.documentPath && !isValidDocumentPath(c.documentPath)) {
// ".../etc/passwd", "<script>", "javascript:..." etc. → NULL.
// Legitime Uploads bleiben unberührt (siehe isValidDocumentPath).
data.documentPath = null;
pathNulled++;
}
if (c.source && !ALLOWED_CONSENT_SOURCES.has(c.source)) {
data.source = 'unknown';
sourceFixed++;
}
if (Object.keys(data).length > 0) {
await prisma.customerConsent.update({ where: { id: c.id }, data });
}
}
console.log(
` → Consent bereinigt: version-stripped=${versionStripped}, ` +
`documentPath-genullt=${pathNulled}, source-whitelist=${sourceFixed}`,
);
}
// documentPath in den weiteren Tabellen prüfen. Schreibend wird er
// server-seitig vom multer-Upload erzeugt falls dort doch mal ein
// dreckiger Wert reingerutscht ist (z.B. aus einem importierten Backup
// vor unseren Sanitization-Fixes), nullen wir ihn hier raus.
// ContractDocument hat documentPath NOT NULL → wir berichten dort nur,
// löschen aber nicht (Records müssten manuell angeschaut werden).
async function cleanupDocumentPaths() {
const findings: { table: string; id: number; value: string }[] = [];
const optional: Array<{
label: string;
fetch: () => Promise<{ id: number; documentPath: string | null }[]>;
update: (id: number) => Promise<unknown>;
}> = [
{
label: 'BankCard',
fetch: () => prisma.bankCard.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.bankCard.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'IdentityDocument',
fetch: () => prisma.identityDocument.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.identityDocument.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'Invoice',
fetch: () => prisma.invoice.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.invoice.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'RepresentativeAuthorization',
fetch: () => prisma.representativeAuthorization.findMany({
select: { id: true, documentPath: true },
}),
update: (id) => prisma.representativeAuthorization.update({
where: { id }, data: { documentPath: null },
}),
},
];
let nulled = 0;
for (const t of optional) {
const rows = await t.fetch();
for (const r of rows) {
if (r.documentPath && !isValidDocumentPath(r.documentPath)) {
findings.push({ table: t.label, id: r.id, value: r.documentPath.slice(0, 80) });
await t.update(r.id);
nulled++;
}
}
}
// ContractDocument: documentPath ist NOT NULL → wir berichten nur.
const contractDocs = await prisma.contractDocument.findMany({
select: { id: true, documentPath: true },
});
let contractDocsDirty = 0;
for (const d of contractDocs) {
if (!isValidDocumentPath(d.documentPath)) {
findings.push({ table: 'ContractDocument', id: d.id, value: d.documentPath.slice(0, 80) });
contractDocsDirty++;
}
}
console.log(` → documentPath bereinigt: ${nulled} genullt, ${contractDocsDirty} ContractDocument-Records auffällig (NOT NULL, manuell prüfen)`);
for (const f of findings.slice(0, 10)) {
console.log(` [${f.table}#${f.id}] "${f.value}"`);
}
}
async function reportOrphanedUsers() {
// User ohne jegliche Rollenzuordnung können sich zwar einloggen, sind aber
// im Permission-System unsichtbar. Meist Überrest von gescheiterten Seeds
// oder manuellen DB-Edits. Wir löschen NICHT (könnte legitime
// Spezial-User treffen) nur warnen.
const orphans = await prisma.user.findMany({
where: { roles: { none: {} } },
select: { id: true, email: true, createdAt: true },
});
if (orphans.length === 0) {
console.log(' → Keine User ohne Rollenzuordnung.');
return;
}
console.log(` ⚠️ ${orphans.length} User ohne Rollenzuordnung:`);
for (const u of orphans.slice(0, 10)) {
console.log(` [User#${u.id}] ${u.email} (created ${u.createdAt.toISOString()})`);
}
console.log(' → Rolle zuweisen oder User löschen.');
}
async function cleanupAppSettings() {
const settings = await prisma.appSetting.findMany();
const removed: string[] = [];
let stripped = 0;
for (const s of settings) {
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
removed.push(s.key);
await prisma.appSetting.delete({ where: { key: s.key } });
continue;
}
if (!HTML_ALLOWED_SETTING_KEYS.has(s.key)) {
const cleaned = stripHtmlString(s.value);
if (cleaned !== s.value) {
await prisma.appSetting.update({ where: { key: s.key }, data: { value: cleaned } });
stripped++;
}
}
}
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
if (stripped > 0) {
console.log(` → AppSettings HTML-gestrippt: ${stripped}`);
}
}
// Pattern, die auf typische Pentest-/Test-Daten hindeuten. Bewusst eng
// gefasst legitime Kunden mit "Hacker" als Nachnamen sollen nicht
// fälschlich getroffen werden (gibt's reichlich, gerade hier).
// Konkret weggelassen: `^hacker@` würde Verwandte/Kunden mit
// `hacker@familie-hacker.de` o.ä. fängen.
const PENTEST_MARKERS = [
/@evil\./i,
/^attacker@/i,
/^pentest@/i,
/<script\b/i, // unverwechselbarer XSS-Marker
/\bonerror\s*=/i, // <img onerror=…>
/javascript:/i, // javascript:-URL
/'\s*OR\s*'1'\s*=\s*'1/i, // SQL-Injection
/\.\.\/.*etc\/passwd/i, // Path-Traversal
];
function looksLikePentestData(value: unknown): boolean {
if (typeof value !== 'string') return false;
return PENTEST_MARKERS.some((re) => re.test(value));
}
async function findOrPurgePentestRecords() {
const purge = process.env.CLEANUP_PURGE_PENTEST === 'true';
const suspect: Array<{ kind: string; id: number; reason: string }> = [];
const customers = await prisma.customer.findMany();
for (const c of customers) {
for (const f of ['email', 'phone', 'mobile', 'firstName', 'lastName', 'companyName', 'notes']) {
if (looksLikePentestData((c as any)[f])) {
suspect.push({ kind: 'Customer', id: c.id, reason: `${f}=${JSON.stringify((c as any)[f]).slice(0, 60)}` });
break;
}
}
}
const users = await prisma.user.findMany();
for (const u of users) {
for (const f of ['email', 'firstName', 'lastName']) {
if (looksLikePentestData((u as any)[f])) {
suspect.push({ kind: 'User', id: u.id, reason: `${f}=${JSON.stringify((u as any)[f]).slice(0, 60)}` });
break;
}
}
}
if (suspect.length === 0) {
console.log(' → Keine Pentest-Marker in Customer/User-Records gefunden.');
return;
}
console.log(`${suspect.length} verdächtige Records (Pentest-Marker):`);
for (const s of suspect) {
console.log(` [${s.kind}#${s.id}] ${s.reason}`);
}
if (!purge) {
console.log(' ️ Zum Löschen Container mit CLEANUP_PURGE_PENTEST=true neu starten,');
console.log(' oder Records manuell über adminer entfernen.');
return;
}
for (const s of suspect) {
if (s.kind === 'Customer') {
await prisma.customer.delete({ where: { id: s.id } }).catch((e: any) => {
console.log(` [Customer#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
});
} else if (s.kind === 'User') {
await prisma.user.delete({ where: { id: s.id } }).catch((e: any) => {
console.log(` [User#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
});
}
}
console.log(`${suspect.length} verdächtige Records gelöscht.`);
}
async function main() {
console.log('=== Cleanup: XSS-Reste + Mass-Assignment-AppSettings ===');
await cleanupXss();
await cleanupAppSettings();
await cleanupConsents();
await cleanupDocumentPaths();
await reportOrphanedUsers();
await findOrPurgePentestRecords();
console.log('=== Fertig. ===');
}
main()
.catch((e) => {
console.error('Cleanup fehlgeschlagen:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -0,0 +1,5 @@
-- AlterTable
-- IF NOT EXISTS: macht das Hochziehen auf prod-DBs sicher, die das Feld
-- über `prisma db push` schon erhalten haben (vor dem Migrations-Workflow).
-- MariaDB unterstützt das seit 10.0.2, MySQL 8 ebenfalls.
ALTER TABLE `Customer` ADD COLUMN IF NOT EXISTS `portalPasswordMustChange` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,23 @@
-- BackupLog: persistierte Historie aller Backup-/Restore-Vorgänge mit
-- Status + Volltext-Log. UI zeigt in zwei Listen (je CREATE und RESTORE).
--
-- IF NOT EXISTS damit Re-Deploys auf bestehende DBs nicht crashen, falls
-- jemand vorher manuell `prisma db push` gefahren hat.
CREATE TABLE IF NOT EXISTS `BackupLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`operation` ENUM('CREATE', 'RESTORE') NOT NULL,
`backupName` VARCHAR(191) NULL,
`success` BOOLEAN NOT NULL,
`durationMs` INTEGER NOT NULL DEFAULT 0,
`summary` TEXT NOT NULL,
`fullLog` LONGTEXT NOT NULL,
`userId` INTEGER NULL,
`userEmail` VARCHAR(191) NULL,
`ipAddress` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `BackupLog_operation_createdAt_idx`(`operation`, `createdAt`),
INDEX `BackupLog_createdAt_idx`(`createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,30 @@
-- Energie-Bonus in Sofort-Bonus + Neukunden-Bonus aufteilen.
-- Bestehende Werte werden nach `instantBonus` migriert (Annahme: bei
-- Bestandsverträgen war "Bonus" üblicherweise der Sofort-Bonus).
-- Wer das anders hatte, kann die Werte über die UI nachträglich verschieben.
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
-- `prisma db push` gefahren hat.
ALTER TABLE `EnergyContractDetails`
ADD COLUMN IF NOT EXISTS `instantBonus` DOUBLE NULL,
ADD COLUMN IF NOT EXISTS `newCustomerBonus` DOUBLE NULL;
-- Daten kopieren, sofern die alte Spalte noch existiert und das Ziel leer ist
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'EnergyContractDetails'
AND COLUMN_NAME = 'bonus'
);
SET @sql := IF(
@col_exists > 0,
'UPDATE `EnergyContractDetails` SET `instantBonus` = `bonus` WHERE `bonus` IS NOT NULL AND `instantBonus` IS NULL',
'SELECT "bonus-Spalte existiert nicht mehr, nichts zu migrieren"'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Alte Spalte droppen, falls vorhanden
ALTER TABLE `EnergyContractDetails` DROP COLUMN IF EXISTS `bonus`;
@@ -0,0 +1,43 @@
-- Zähler bekommen eine Lieferadresse, an der sie installiert sind.
-- Optional, damit Bestandszähler (vor diesem Feature) nicht brechen
-- die werden manuell nachgepflegt. ON DELETE SET NULL, damit ein
-- gelöschter Adresseintrag den Zähler nicht killt.
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
-- `prisma db push` gefahren hat.
ALTER TABLE `Meter`
ADD COLUMN IF NOT EXISTS `addressId` INT NULL;
-- Index nur anlegen, wenn er noch nicht da ist
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'Meter'
AND INDEX_NAME = 'Meter_addressId_fkey'
);
SET @sql := IF(
@idx_exists = 0,
'CREATE INDEX `Meter_addressId_fkey` ON `Meter`(`addressId`)',
'SELECT "Index existiert bereits"'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Foreign Key nur anlegen, wenn er noch nicht da ist
SET @fk_exists := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'Meter'
AND CONSTRAINT_NAME = 'Meter_addressId_fkey'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
);
SET @sql := IF(
@fk_exists = 0,
'ALTER TABLE `Meter` ADD CONSTRAINT `Meter_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
'SELECT "FK existiert bereits"'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,46 @@
-- Folgezähler-Kette: Meter zeigt optional auf den Vorgänger.
-- Beim Wechsel können wir dann sowohl die Kette für die UI anzeigen
-- als auch alle Verträge mit dem Vorgänger automatisch auf den
-- Nachfolger umstellen.
--
-- ON DELETE SET NULL, damit ein versehentlich gelöschter Vorgänger
-- den Nachfolger nicht killt.
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand
-- schon `prisma db push` gefahren hat.
ALTER TABLE `Meter`
ADD COLUMN IF NOT EXISTS `predecessorMeterId` INT NULL;
-- Index nur anlegen, wenn er noch nicht da ist
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'Meter'
AND INDEX_NAME = 'Meter_predecessorMeterId_fkey'
);
SET @sql := IF(
@idx_exists = 0,
'CREATE INDEX `Meter_predecessorMeterId_fkey` ON `Meter`(`predecessorMeterId`)',
'SELECT "Index existiert bereits"'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Foreign Key nur anlegen, wenn er noch nicht da ist
SET @fk_exists := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'Meter'
AND CONSTRAINT_NAME = 'Meter_predecessorMeterId_fkey'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
);
SET @sql := IF(
@fk_exists = 0,
'ALTER TABLE `Meter` ADD CONSTRAINT `Meter_predecessorMeterId_fkey` FOREIGN KEY (`predecessorMeterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
'SELECT "FK existiert bereits"'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,9 @@
-- SIM-Karte bekommt einen optionalen "Kartennutzer" relevant bei Firmen-
-- und Familienverträgen, wo der Vertragsinhaber (Firma/Eltern) nicht
-- gleich dem tatsächlichen Nutzer (Mitarbeiter/Kind) ist.
--
-- 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 `cardUser` VARCHAR(191) NULL;
@@ -0,0 +1,14 @@
-- Telefonnummern bekommen eine zusätzliche Spalte `areaCode`, damit
-- Wechselauftrag-PDFs die Vorwahl verlässlich befüllen können, ohne
-- den heuristischen String-Split, der bei Sonderformaten danebenfasst.
--
-- `phoneNumber` bleibt unverändert die komplette Nummer (Vorwahl +
-- Anschlussnummer), damit Reports/Listen/Suchen weiter funktionieren.
-- `areaCode` ist optional, weil Bestandsnummern erst beim nächsten
-- Edit nachgepflegt werden.
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand
-- schon `prisma db push` gefahren hat.
ALTER TABLE `PhoneNumber`
ADD COLUMN IF NOT EXISTS `areaCode` VARCHAR(191) NULL;
@@ -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;
+94
View File
@@ -0,0 +1,94 @@
/**
* Notfall-Reset: setzt das Passwort eines Mitarbeiter-Users direkt in der DB.
* Wird vom scripts/admin-rescue.sh-Wrapper im Container ausgeführt, wenn ein
* Admin sich ausgesperrt hat (z.B. weil admin@admin.com keine echte
* E-Mail-Adresse ist und der Passwort-vergessen-Flow daher nicht greift).
*
* Aufruf:
* npx tsx prisma/reset-admin-password.ts <email> # generiert PW
* npx tsx prisma/reset-admin-password.ts <email> <passwort> # eigenes PW
*
* Setzt zusätzlich `tokenInvalidatedAt = now()` → alle bestehenden Sessions
* dieses Users werden sofort ausgeloggt (Defense gegen Wiederverwendung
* gestohlener Tokens).
*/
import bcrypt from 'bcryptjs';
import prisma from '../src/lib/prisma.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../src/utils/passwordGenerator.js';
const BCRYPT_COST = 12;
function generateRescuePassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const special = '!@#$%&*+=?';
const all = upper + lower + digits + special;
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
for (let i = chars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
async function main() {
const email = process.argv[2];
const providedPw = process.argv[3];
if (!email) {
console.error('Aufruf: npx tsx prisma/reset-admin-password.ts <email> [passwort]');
process.exit(1);
}
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, email: true, firstName: true, lastName: true },
});
if (!user) {
console.error(`User "${email}" nicht gefunden.`);
process.exit(2);
}
let plain: string;
if (providedPw) {
const c = validatePasswordComplexity(providedPw, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
console.error('Übergebenes Passwort erfüllt Mitarbeiter-Komplexität nicht:');
for (const e of c.errors) console.error(' - ' + e);
process.exit(3);
}
plain = providedPw;
} else {
plain = generateRescuePassword();
}
const hash = await bcrypt.hash(plain, BCRYPT_COST);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
passwordResetToken: null,
passwordResetExpiresAt: null,
tokenInvalidatedAt: new Date(),
},
});
console.log('========================================================');
console.log(` User: ${user.email} (${user.firstName} ${user.lastName})`);
console.log(` Neues Passwort: ${plain}`);
console.log(' ⚠️ Wird hier EINMAL ausgegeben sofort kopieren!');
console.log(' Alle bestehenden Sessions wurden invalidiert.');
console.log('========================================================');
}
main()
.catch((e) => {
console.error('Reset fehlgeschlagen:', e);
process.exit(99);
})
.finally(async () => {
await prisma.$disconnect();
});
+79 -2
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 =====
@@ -172,6 +173,10 @@ model Customer {
portalPasswordResetExpiresAt DateTime?
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
portalTokenInvalidatedAt DateTime?
// Einmalpasswort: gesetzt durch "Zugangsdaten versenden"-Button. Beim ersten
// erfolgreichen Login wird der Hash sofort gelöscht (OTP verbraucht) und
// Frontend in Force-Change-Password-Flow geleitet.
portalPasswordMustChange Boolean @default(false)
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
lastBirthdayGreetingYear Int?
@@ -277,6 +282,7 @@ model Address {
ownerEmail String?
contractsAsDelivery Contract[] @relation("DeliveryAddress")
contractsAsBilling Contract[] @relation("BillingAddress")
meters Meter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -396,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())
@@ -475,11 +487,21 @@ model Meter {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
// Lieferadresse, an der der Zähler hängt. Optional, weil Bestandszähler
// vor dem Feature noch keine Adresse haben (werden manuell nachgepflegt).
addressId Int?
address Address? @relation(fields: [addressId], references: [id], onDelete: SetNull)
meterNumber String
type MeterType
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
location String?
isActive Boolean @default(true)
// Folgezähler-Kette: zeigt auf den Vorgänger, den dieser Zähler abgelöst hat.
// Wird beim Anlegen als Folgezähler gesetzt; informational + zum Anzeigen
// der Kette. Auto-Propagation auf Verträge passiert beim Create.
predecessorMeterId Int?
predecessor Meter? @relation("MeterSuccessor", fields: [predecessorMeterId], references: [id], onDelete: SetNull)
successors Meter[] @relation("MeterSuccessor")
readings MeterReading[]
energyDetails EnergyContractDetails[]
contractMeters ContractMeter[] @relation("ContractMeters")
@@ -554,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[]
@@ -671,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
@@ -801,7 +835,14 @@ model EnergyContractDetails {
basePrice Float? // €/Monat
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
bonus Float?
// Bonus wurde 2026-05-24 in zwei Felder aufgeteilt: Sofort-Bonus
// (Auszahlung kurz nach Wechsel) + Neukunden-Bonus (Auszahlung am
// Vertragsende / nach 12 Monaten). Beide werden im Detail als
// Gesamtbonus aufsummiert und in der Kostenvorschau einzeln
// dargestellt. Migration kopiert das alte `bonus` nach
// `instantBonus` (Annahme: meistgenutzte Variante).
instantBonus Float? // Sofort-Bonus
newCustomerBonus Float? // Neukunden-Bonus
previousProviderName String?
previousCustomerNumber String?
invoices Invoice[] // Rechnungen
@@ -870,7 +911,12 @@ model PhoneNumber {
id Int @id @default(autoincrement())
internetContractDetailsId Int
internetDetails InternetContractDetails @relation(fields: [internetContractDetailsId], references: [id], onDelete: Cascade)
// phoneNumber bleibt die komplette Nummer (Vorwahl + Anschluss) für
// bestehende Reports/Listen. Vorwahl wird zusätzlich getrennt erfasst,
// damit Auftragsformulare (Wechselauftrag PDF) sie verlässlich
// befüllen können ohne heuristischen String-Split.
phoneNumber String
areaCode String?
isMain Boolean @default(false)
// SIP-Zugangsdaten
sipUsername String?
@@ -906,6 +952,10 @@ 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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -1142,6 +1192,33 @@ enum SecuritySeverity {
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
}
enum BackupOperation {
CREATE
RESTORE
}
// Persistiertes Log für Backup-Vorgänge.
// `summary` ist die einzeilige Anzeige in der Liste (z.B. "4859 Datensätze
// wiederhergestellt"), `fullLog` der detaillierte Output inkl. Stack-Trace
// für das Modal. Wird beim Build/Restore in `backup.controller.ts`
// geschrieben.
model BackupLog {
id Int @id @default(autoincrement())
operation BackupOperation
backupName String?
success Boolean
durationMs Int @default(0)
summary String @db.Text
fullLog String @db.LongText
userId Int?
userEmail String?
ipAddress String?
createdAt DateTime @default(now())
@@index([operation, createdAt])
@@index([createdAt])
}
model SecurityEvent {
id Int @id @default(autoincrement())
type SecurityEventType
+48 -3
View File
@@ -221,8 +221,41 @@ async function main() {
console.log('Roles created');
// Create admin user
const hashedPassword = await bcrypt.hash('admin', 10);
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
// Komplexitätspolicy). Stattdessen:
// - SEED_ADMIN_PASSWORD-ENV → wird verwendet (z.B. via docker-compose env)
// - sonst → zufälliges 16-Zeichen-Passwort, wird ein einziges Mal beim
// Seed in stdout ausgegeben. Wer das Log nicht sieht, muss
// Passwort-vergessen-Flow nutzen.
// Hash-Cost: 12 (OWASP 2026), nicht mehr 10.
function generateInitialPassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const special = '!@#$%&*+=?';
const all = upper + lower + digits + special;
// Kryptografisch sichere Auswahl Math.random() ist vorhersagbar
// und reicht für ein Initial-Admin-Passwort nicht (Pentest 2026-05-20).
const pick = (s: string) => s[crypto.randomInt(0, s.length)];
// mind. einen aus jeder Klasse + Rest zufällig
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
// 28 Zeichen → Komplexität + komfortable Marge über dem 25-Zeichen-
// Mitarbeiter-Schwellwert (Pentest Runde 13).
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
// Fisher-Yates Shuffle mit kryptografisch starkem Random.
for (let i = chars.length - 1; i > 0; i--) {
const j = crypto.randomInt(0, i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
const envPassword = process.env.SEED_ADMIN_PASSWORD;
const adminPlainPassword = envPassword && envPassword.length >= 25
? envPassword
: generateInitialPassword();
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
const adminUser = await prisma.user.upsert({
where: { email: 'admin@admin.com' },
@@ -238,7 +271,19 @@ async function main() {
},
});
console.log('Admin user created: admin@admin.com / admin');
console.log('========================================================');
console.log(' Admin-User: admin@admin.com');
if (envPassword && envPassword.length >= 25) {
console.log(' Passwort: aus SEED_ADMIN_PASSWORD');
} else {
if (envPassword && envPassword.length < 25) {
console.log(' ⚠️ SEED_ADMIN_PASSWORD < 25 Zeichen, wird ignoriert!');
}
console.log(` Initial-Passwort: ${adminPlainPassword}`);
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
console.log(' Bitte sofort nach dem ersten Login ändern.');
}
console.log('========================================================');
// Create some sales platforms
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
+162
View File
@@ -0,0 +1,162 @@
/**
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
*
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
* System schon installiert hat, bekommt nachträglich hinzugefügte
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
*
* Dieses Skript synchronisiert ausschließlich:
* - Permission-Katalog (resource/action-Paare aus dem Code)
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
* Mitarbeiter (Nur-Lesen), Kunde)
*
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
* laufenden Prod-DBs sicher.
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
customers: ['create', 'read', 'update', 'delete'],
contracts: ['create', 'read', 'update', 'delete'],
users: ['create', 'read', 'update', 'delete'],
platforms: ['create', 'read', 'update', 'delete'],
providers: ['create', 'read', 'update', 'delete'],
tariffs: ['create', 'read', 'update', 'delete'],
'cancellation-periods': ['create', 'read', 'update', 'delete'],
'contract-durations': ['create', 'read', 'update', 'delete'],
'contract-categories': ['create', 'read', 'update', 'delete'],
'email-providers': ['create', 'read', 'update', 'delete'],
settings: ['read', 'update'],
developer: ['access'],
emails: ['delete'],
audit: ['read', 'export', 'admin'],
gdpr: ['export', 'delete', 'admin'],
};
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
const existing = await prisma.rolePermission.findMany({
where: { roleId },
select: { permissionId: true },
});
const existingIds = new Set(existing.map((e) => e.permissionId));
const targetIds = new Set(permissionIds);
const missing = permissionIds.filter((id) => !existingIds.has(id));
if (missing.length > 0) {
await prisma.rolePermission.createMany({
data: missing.map((permissionId) => ({ roleId, permissionId })),
skipDuplicates: true,
});
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
}
const excess = existing
.filter((e) => !targetIds.has(e.permissionId))
.map((e) => e.permissionId);
if (excess.length > 0) {
await prisma.rolePermission.deleteMany({
where: { roleId, permissionId: { in: excess } },
});
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
}
}
async function main() {
console.log('[sync-roles] Permissions-Katalog upserten…');
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
for (const action of actions) {
await prisma.permission.upsert({
where: { resource_action: { resource, action } },
update: {},
create: { resource, action },
});
}
}
const allPermissions = await prisma.permission.findMany();
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
// sind separate hidden roles, über Checkboxen zugewiesen)
const adminPermIds = allPermissions
.filter(
(p) =>
!(p.resource === 'developer' && p.action === 'access') &&
p.resource !== 'audit' &&
p.resource !== 'gdpr'
)
.map((p) => p.id);
// Developer: alles
const developerPermIds = allPermissions.map((p) => p.id);
// DSGVO: audit + gdpr komplett
const gdprPermIds = allPermissions
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
.map((p) => p.id);
// Mitarbeiter: customers + contracts + read auf Stammdaten
const employeePermIds = allPermissions
.filter(
(p) =>
p.resource === 'customers' ||
p.resource === 'contracts' ||
(p.action === 'read' &&
[
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
].includes(p.resource))
)
.map((p) => p.id);
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
const readOnlyResources = [
'customers',
'contracts',
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
];
const readOnlyPermIds = allPermissions
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
.map((p) => p.id);
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
];
for (const r of rolesSpec) {
const role = await prisma.role.upsert({
where: { name: r.name },
update: { description: r.description },
create: { name: r.name, description: r.description },
});
await syncRolePermissions(role.id, r.permIds);
}
console.log('[sync-roles] fertig.');
}
main()
.catch((e) => {
console.error('[sync-roles] Fehler:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -41,10 +41,32 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
return;
}
// Whitelist-Check (Pentest Runde 11, M1)
if (!appSettingService.isAllowedSettingKey(key)) {
res.status(400).json({
success: false,
error: `Unbekannter Setting-Key: ${key}`,
} as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit
const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-';
const newValue = String(value);
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
// sonst ungefiltert in E-Mail-Templates / PDFs.
const stripped = appSettingService.sanitizeSettingValue(key, String(value));
// Schema-spezifische Validierung (URL/Email/Int/Bool). Pentest
// 2026-05-28, LOW 34.5: portalLoginUrl nahm `/relative/path` und
// `http://192.168.1.1` ungefiltert entgegen → Open-Redirect /
// SSRF in der versendeten Mail.
const validation = appSettingService.validateSettingValue(key, stripped);
if (!validation.ok) {
res.status(400).json({ success: false, error: validation.error } as ApiResponse);
return;
}
const newValue = validation.value;
await appSettingService.setSetting(key, newValue);
@@ -78,12 +100,35 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
return;
}
// Vorherige Werte laden für Audit
// Whitelist-Check für jeden Key (Pentest Runde 11, M1: Mass Assignment)
const unknownKeys = Object.keys(settings).filter(
(k) => !appSettingService.isAllowedSettingKey(k),
);
if (unknownKeys.length > 0) {
res.status(400).json({
success: false,
error: `Unbekannte Setting-Keys: ${unknownKeys.join(', ')}`,
} as ApiResponse);
return;
}
// Vorherige Werte laden für Audit. Validierung erfolgt vor dem
// ersten Schreibzugriff, damit ein Bulk-PUT mit einem ungültigen
// Wert nicht die anderen Werte halb-committed liegen lässt.
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const sanitizedEntries: Array<{ key: string; oldValue: string; newValue: string }> = [];
for (const [key, value] of Object.entries(settings)) {
const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-';
const newValue = String(value);
const stripped = appSettingService.sanitizeSettingValue(key, String(value));
const validation = appSettingService.validateSettingValue(key, stripped);
if (!validation.ok) {
res.status(400).json({ success: false, error: `${key}: ${validation.error}` } as ApiResponse);
return;
}
sanitizedEntries.push({ key, oldValue, newValue: validation.value });
}
for (const { key, oldValue, newValue } of sanitizedEntries) {
if (oldValue !== newValue) {
changes[key] = { von: oldValue, nach: newValue };
}
+123 -4
View File
@@ -3,6 +3,7 @@ import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH, PORTAL_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
@@ -26,6 +27,26 @@ function clearRefreshCookie(res: Response): void {
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
}
// Whitelist von Fehlermeldungen, die wir an Login-Clients durchreichen dürfen.
// ALLES andere (Prisma-Internals, DB-Connection-Errors, Schema-Fehler, ...)
// wird als generisches "Anmeldung fehlgeschlagen" maskiert die Original-
// Message bleibt im Server-Log, leakt aber nicht im HTTP-Response. Pentest
// Runde 3 (2026-05-16): `prisma.customer.findUnique() invocation: The column
// X does not exist` war im Body sichtbar → Tabellen-/Spaltennamen geleakt.
const SAFE_LOGIN_ERRORS = new Set([
'Ungültige Anmeldedaten',
'E-Mail und Passwort erforderlich',
]);
function safeLoginError(err: unknown): string {
if (err instanceof Error && SAFE_LOGIN_ERRORS.has(err.message)) {
return err.message;
}
if (err instanceof Error) {
console.error('[Login] Unerwarteter Fehler (maskiert):', err.message);
}
return 'Anmeldung fehlgeschlagen';
}
// Mitarbeiter-Login
export async function login(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
@@ -67,7 +88,7 @@ export async function login(req: Request, res: Response): Promise<void> {
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
error: safeLoginError(error),
} as ApiResponse);
}
}
@@ -111,7 +132,7 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
error: safeLoginError(error),
} as ApiResponse);
}
}
@@ -223,10 +244,15 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
return;
}
if (password.length < 6) {
// Audience anhand des Tokens bestimmen, damit Admin-Reset 25 Zeichen
// verlangt und Portal-Customer-Reset weiterhin 12 reicht.
const audience = await authService.getPasswordResetAudience(token);
const minLength = audience === 'admin' ? STAFF_MIN_PASSWORD_LENGTH : PORTAL_MIN_PASSWORD_LENGTH;
const complexity = validatePasswordComplexity(password, { minLength });
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
@@ -355,6 +381,16 @@ export async function register(req: Request, res: Response): Promise<void> {
return;
}
// Mitarbeiter-Anlage: 25-Zeichen-Schwellwert
const complexity = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
const user = await authService.createUser({
email,
password,
@@ -374,3 +410,86 @@ export async function register(req: Request, res: Response): Promise<void> {
} as ApiResponse);
}
}
// Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL
// brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer
// authentifiziert sich normal per Bearer-Header. Antwort: ein download-
// scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert.
export async function createDownloadToken(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
const payload: any = {
email: req.user.email,
permissions: req.user.permissions,
isCustomerPortal: !!req.user.isCustomerPortal,
};
if (req.user.userId) payload.userId = req.user.userId;
if (req.user.customerId) payload.customerId = req.user.customerId;
if ((req.user as any).representedCustomerIds) {
payload.representedCustomerIds = (req.user as any).representedCustomerIds;
}
const token = authService.signDownloadToken(payload);
res.json({ success: true, data: { token } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Erstellen des Download-Tokens',
} as ApiResponse);
}
}
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
// loggt aus und schickt zurück zum Login.
export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user?.isCustomerPortal || !req.user?.customerId) {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Login',
} as ApiResponse);
return;
}
// Pflicht-Check: NUR im Einmalpasswort-Flow erlaubt. Sonst könnte jeder
// eingeloggte Portal-User sein Passwort ohne Kenntnis des alten ändern
// (z.B. nach XSS-Token-Diebstahl). Pentest Runde 5 (2026-05-16) KRITISCH.
const customer = await prisma.customer.findUnique({
where: { id: req.user.customerId },
select: { portalPasswordMustChange: true },
});
if (!customer?.portalPasswordMustChange) {
res.status(403).json({
success: false,
error: 'Nicht erlaubt',
} as ApiResponse);
return;
}
const { newPassword } = req.body || {};
if (!newPassword || typeof newPassword !== 'string') {
res.status(400).json({
success: false,
error: 'Neues Passwort erforderlich',
} as ApiResponse);
return;
}
const complexity = validatePasswordComplexity(newPassword);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
await authService.changeInitialPortalPassword(req.user.customerId, newPassword);
clearRefreshCookie(res);
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
} as ApiResponse);
}
}
+258 -9
View File
@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import * as backupService from '../services/backup.service.js';
import prisma from '../lib/prisma.js';
/**
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
@@ -11,6 +12,83 @@ function isValidBackupName(name: string): boolean {
}
import { logChange } from '../services/audit.service.js';
// Fängt console.log/info/warn/error für die Laufzeit einer Operation in
// einen Puffer mit ab (zusätzlich landet alles weiterhin in stdout/stderr).
// Wird in createBackup/restoreBackup verwendet, um den vollständigen
// Verlauf in `BackupLog.fullLog` zu persistieren. Da die Backup-Operationen
// in der Praxis nicht parallel laufen (Single-User-Admin-UI), reicht die
// process-globale Patch-Variante.
function startLogCapture(): { lines: string[]; restore: () => void } {
const lines: string[] = [];
const orig = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
};
function fmt(args: unknown[]): string {
return args
.map((a) => {
if (a instanceof Error) return a.stack || a.message;
if (typeof a === 'object') {
try {
return JSON.stringify(a);
} catch {
return String(a);
}
}
return String(a);
})
.join(' ');
}
console.log = (...args: unknown[]) => { lines.push(fmt(args)); orig.log(...args); };
console.info = (...args: unknown[]) => { lines.push(fmt(args)); orig.info(...args); };
console.warn = (...args: unknown[]) => { lines.push(`[WARN] ${fmt(args)}`); orig.warn(...args); };
console.error = (...args: unknown[]) => { lines.push(`[ERROR] ${fmt(args)}`); orig.error(...args); };
return {
lines,
restore: () => {
console.log = orig.log;
console.info = orig.info;
console.warn = orig.warn;
console.error = orig.error;
},
};
}
async function recordBackupLog(opts: {
req: Request;
operation: 'CREATE' | 'RESTORE';
backupName: string | null;
success: boolean;
durationMs: number;
summary: string;
fullLog: string;
}) {
try {
const user = (opts.req as any).user;
await prisma.backupLog.create({
data: {
operation: opts.operation,
backupName: opts.backupName,
success: opts.success,
durationMs: opts.durationMs,
summary: opts.summary.slice(0, 2000),
// LongText: bis ~4 GB, aber wir cappen bei 1 MB damit nichts entgleist
fullLog: opts.fullLog.slice(0, 1_000_000),
userId: user?.userId ?? null,
userEmail: user?.email ?? null,
ipAddress:
(opts.req as any).socket?.remoteAddress ||
(opts.req.headers?.['x-forwarded-for'] as string) ||
null,
},
});
} catch (err) {
console.error('[BackupLog] Konnte Log nicht persistieren:', err);
}
}
/**
* Liste aller Backups abrufen
* GET /api/settings/backups
@@ -29,19 +107,44 @@ export async function listBackups(req: Request, res: Response) {
* POST /api/settings/backup
*/
export async function createBackup(req: Request, res: Response) {
const start = Date.now();
const capture = startLogCapture();
try {
const result = await backupService.createBackup();
const durationMs = Date.now() - start;
if (result.success) {
capture.restore();
const summary = `Backup ${result.backupName} erstellt (${(durationMs / 1000).toFixed(1)}s)`;
await recordBackupLog({
req, operation: 'CREATE', backupName: result.backupName ?? null,
success: true, durationMs, summary,
fullLog: capture.lines.join('\n') || summary,
});
await logChange({
req, action: 'CREATE', resourceType: 'Backup',
label: `Backup ${result.backupName} erstellt`,
});
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
} else {
capture.restore();
await recordBackupLog({
req, operation: 'CREATE', backupName: null,
success: false, durationMs,
summary: `Backup fehlgeschlagen: ${result.error || 'unbekannt'}`,
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
});
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
}
} catch (error: any) {
const durationMs = Date.now() - start;
capture.restore();
await recordBackupLog({
req, operation: 'CREATE', backupName: null,
success: false, durationMs,
summary: `Fehler: ${error?.message || 'unbekannt'}`,
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
});
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
}
}
@@ -50,17 +153,63 @@ export async function createBackup(req: Request, res: Response) {
* Backup wiederherstellen
* POST /api/settings/backup/:name/restore
*/
// Macht eine Fehlermeldung admin-lesbar OHNE den globalen ORM-Leak-Filter
// auszulösen: Stack-Frames raus, "TypeError: …" → "Code-Fehler: …",
// "Cannot read properties of undefined" → "Interner Code-Fehler".
// Vollständiger Stack landet immer im Server-Log (siehe `console.error`).
function makeRestoreErrorReadable(raw: unknown): string {
if (!raw) return 'Unbekannter Fehler';
let s = typeof raw === 'string' ? raw : (raw as any)?.message || String(raw);
// Stack-Frames " at …(…:123:45)" abschneiden
s = s.split('\n').filter((line: string) => !/^\s*at\s+/.test(line)).join('\n').trim();
// Bekannte JS-Runtime-Marker rephrasen, damit der orm-leak-guard nicht
// alles auf "Operation fehlgeschlagen" maskiert.
s = s
.replace(/^TypeError:?\s*/i, 'Code-Fehler: ')
.replace(/^ReferenceError:?\s*/i, 'Code-Fehler: ')
.replace(/^SyntaxError:?\s*/i, 'Code-Fehler: ')
.replace(/^RangeError:?\s*/i, 'Code-Fehler: ')
.replace(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '([^']+)'\)/i, 'Wert fehlt: $2')
.replace(/is not a function/i, '(ungültiger Funktionsaufruf)')
.replace(/is not defined$/i, '(Wert nicht definiert)')
.replace(/Invalid `prisma\.[^`]+`/i, 'DB-Fehler');
return s.slice(0, 500); // Längenlimit für UI
}
export async function restoreBackup(req: Request, res: Response) {
const start = Date.now();
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
// Pflicht-Confirm im Body, gleiche Defensive wie factoryReset.
// Pentest 2026-05-19 (KRITISCH): leerer POST-Body löste vorher
// sofort den destruktiven Restore aus ein versehentlicher
// Re-Fire (Browser-Tab, CSRF auf eingeloggten Admin, doppelter
// Klick) konnte die DB ungewollt überschreiben. Der String ist
// bewusst ein unique Magic-Value, kein Boolean.
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
if (confirm !== 'RESTORE-BESTAETIGT') {
return res.status(400).json({
error: 'Bestätigung fehlt. Body muss { "confirm": "RESTORE-BESTAETIGT" } enthalten.',
});
}
const capture = startLogCapture();
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
const result = await backupService.restoreBackup(name);
const durationMs = Date.now() - start;
if (result.success) {
capture.restore();
const summary = `${result.restoredRecords} Datensätze, ${result.restoredFiles || 0} Dateien (${(durationMs / 1000).toFixed(1)}s)`;
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: true, durationMs, summary,
fullLog: capture.lines.join('\n') || summary,
});
await logChange({
req, action: 'UPDATE', resourceType: 'Backup',
label: `Backup ${name} wiederhergestellt`,
@@ -73,10 +222,35 @@ export async function restoreBackup(req: Request, res: Response) {
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
});
} else {
res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: result.error });
console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
capture.restore();
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: false, durationMs,
summary: `Fehlgeschlagen: ${makeRestoreErrorReadable(result.error)}`,
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
});
res.status(500).json({
error: 'Wiederherstellung fehlgeschlagen',
details: makeRestoreErrorReadable(result.error),
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
});
}
} catch (error: any) {
res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: error.message });
const durationMs = Date.now() - start;
console.error(`[restore] Exception bei Backup ${name}:`, error?.stack || error);
capture.restore();
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: false, durationMs,
summary: `Exception: ${makeRestoreErrorReadable(error)}`,
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
});
res.status(500).json({
error: 'Fehler bei der Wiederherstellung',
details: makeRestoreErrorReadable(error),
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
});
}
}
@@ -176,6 +350,22 @@ export async function uploadBackup(req: Request, res: Response) {
*/
export async function factoryReset(req: Request, res: Response) {
try {
// Bestätigung erforderlich: client MUSS explizit
// `confirm: "FACTORY-RESET-BESTAETIGT"` schicken. Ohne diesen Schritt
// konnte ein eingeloggter Admin die komplette DB mit einem einfachen
// POST plätten (Pentest Runde 11 (2026-05-18) C2 KRITISCH:
// 3× DB-Plättung in einer Session). Body-Wert ist absichtlich ein
// unique String und kein boolean, damit kein Auto-JSON-Tooling /
// Replay-Angriff aus Versehen triggern kann.
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
if (confirm !== 'FACTORY-RESET-BESTAETIGT') {
res.status(400).json({
success: false,
error: 'Bestätigung fehlt. Body muss { "confirm": "FACTORY-RESET-BESTAETIGT" } enthalten.',
});
return;
}
const result = await backupService.factoryReset();
if (result.success) {
@@ -190,6 +380,65 @@ export async function factoryReset(req: Request, res: Response) {
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
}
} catch (error: any) {
res.status(500).json({ error: 'Fehler bei Werkseinstellungen', details: error.message });
res.status(500).json({ error: 'Fehler bei Werkseinstellungen' });
console.error('factoryReset error:', error);
}
}
/**
* Liste der Backup-Logs (CREATE oder RESTORE)
* GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
* Liefert die Übersichtsdaten OHNE den großen fullLog.
*/
export async function listBackupLogs(req: Request, res: Response) {
try {
const op = String(req.query.operation || '').toUpperCase();
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10) || 50, 1), 200);
const where: any = {};
if (op === 'CREATE' || op === 'RESTORE') {
where.operation = op;
}
const logs = await prisma.backupLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
operation: true,
backupName: true,
success: true,
durationMs: true,
summary: true,
userEmail: true,
ipAddress: true,
createdAt: true,
},
});
res.json({ data: logs });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden der Logs', details: error.message });
}
}
/**
* Detail eines Backup-Logs inkl. fullLog
* GET /api/settings/backup-logs/:id
*/
export async function getBackupLogDetail(req: Request, res: Response) {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
return res.status(400).json({ error: 'Ungültige ID' });
}
const log = await prisma.backupLog.findUnique({ where: { id } });
if (!log) {
return res.status(404).json({ error: 'Log-Eintrag nicht gefunden' });
}
res.json({ data: log });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden des Log-Details', details: error.message });
}
}
@@ -11,6 +11,13 @@ import { createAuditLog } from '../services/audit.service.js';
*/
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
try {
// Portal-Kunden haben hier nichts zu suchen. Endpoint listet Namen, E-Mail,
// Telefon und Geburtsdatum ALLER Kunden ausschließlich Mitarbeiter-UI.
// Pentest Runde 6 (2026-05-16) HOCH.
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Nicht erlaubt' });
return;
}
const past = req.query.past ? parseInt(String(req.query.past)) : 7;
const future = req.query.future ? parseInt(String(req.query.future)) : 30;
+442 -62
View File
@@ -8,16 +8,17 @@ 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 } from '../types/index.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';
import fs from 'fs';
import { AuthRequest } from '../types/index.js';
import {
canAccessCustomer,
canAccessContract,
@@ -40,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;
@@ -80,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,
@@ -139,9 +217,10 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
}
// E-Mail als gelesen/ungelesen markieren
export async function markAsRead(req: Request, res: Response): Promise<void> {
export async function markAsRead(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const { isRead } = req.body;
if (isRead) {
@@ -161,9 +240,10 @@ export async function markAsRead(req: Request, res: Response): Promise<void> {
}
// E-Mail Stern umschalten
export async function toggleStar(req: Request, res: Response): Promise<void> {
export async function toggleStar(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const isStarred = await cachedEmailService.toggleEmailStar(id);
res.json({ success: true, data: { isStarred } } as ApiResponse);
@@ -179,10 +259,12 @@ export async function toggleStar(req: Request, res: Response): Promise<void> {
// ==================== CONTRACT ASSIGNMENT ====================
// E-Mail einem Vertrag zuordnen
export async function assignToContract(req: Request, res: Response): Promise<void> {
export async function assignToContract(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { contractId } = req.body;
if (!(await canAccessContract(req, res, contractId))) return;
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
@@ -198,9 +280,10 @@ export async function assignToContract(req: Request, res: Response): Promise<voi
}
// Vertragszuordnung aufheben
export async function unassignFromContract(req: Request, res: Response): Promise<void> {
export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const email = await cachedEmailService.unassignEmailFromContract(emailId);
@@ -232,12 +315,17 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise<
}
}
// E-Mail-Anzahl pro Ordner für einen Vertrag
export async function getContractFolderCounts(req: Request, res: Response): Promise<void> {
// 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) {
@@ -255,6 +343,16 @@ export async function getContractFolderCounts(req: Request, res: Response): Prom
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
try {
const stressfreiEmailId = parseInt(req.params.id);
// Mitarbeiter brauchen customers:update (wie früher), Portal-Kunden
// brauchen keine Perm nur Eigentum am Konto (Owner-Check unten).
// Trennung der Threat-Modelle: Portal-User dürfen IHR eigenes
// Postfach syncen, sollen aber nicht Mitarbeiter-Updates triggern.
const isPortal = !!req.user?.isCustomerPortal;
const hasUpdatePerm = req.user?.permissions?.includes('customers:update') ?? false;
if (!isPortal && !hasUpdatePerm) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' } as ApiResponse);
return;
}
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const fullSync = req.query.full === 'true';
@@ -294,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 {
@@ -310,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);
@@ -563,24 +754,44 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
}
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
const INLINE_SAFE_TYPES = new Set([
'application/pdf',
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
'text/plain',
]);
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
// SVG kann Skripte enthalten → niemals inline
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
// o.ä. sein. Für inline-Preview verlässt sich der Server nicht auf den
// gemeldeten Type, sondern prüft die Magic-Bytes des Buffer-Inhalts.
// Real-world-Problem (intern gemeldet 2026-05-30): manche Mail-Clients
// setzen für PDF-Anhänge `application/octet-stream` → unser alter
// Whitelist-Check fiel auf attachment zurück, der Browser öffnete
// trotz target="_blank" keinen neuen Tab. Mit Magic-Byte-Detection
// wird der echte Typ erkannt und inline-Preview klappt zuverlässig.
const buf: Buffer = attachment.content;
let detectedType: string | null = null;
if (buf.length >= 5 && buf.subarray(0, 5).toString('latin1') === '%PDF-') {
detectedType = 'application/pdf';
} else if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
detectedType = 'image/png';
} else if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
detectedType = 'image/jpeg';
} else if (buf.length >= 6 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a')) {
detectedType = 'image/gif';
} else if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
detectedType = 'image/webp';
} else if (buf.length >= 1 && (attachment.contentType || '').toLowerCase().startsWith('text/plain')) {
// text/plain hat keine eindeutige Magic-Byte akzeptieren wenn
// der IMAP-Header das so meldet und Inhalt nur druckbare ASCII/UTF-8 ist.
// Konservative Prüfung: keine HTML-Tag-Anfänge.
const sample = buf.subarray(0, Math.min(buf.length, 256)).toString('utf8');
if (!/<[a-z!\/?]/i.test(sample)) {
detectedType = 'text/plain; charset=utf-8';
}
}
const isSafeInline = detectedType !== null;
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
// Bei sicherem Inline-Typ: erkannten Type setzen (überschreibt
// eventuell falsches application/octet-stream aus IMAP). Sonst
// octet-stream erzwingen, damit der Browser nichts erraten kann.
res.setHeader('Content-Type', isSafeInline ? detectedType! : 'application/octet-stream');
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
res.setHeader('Content-Length', attachment.size);
res.send(attachment.content);
@@ -611,9 +822,10 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
// ==================== MAILBOX ACCOUNTS ====================
// Mailbox-Konten eines Kunden abrufen
export async function getMailboxAccounts(req: Request, res: Response): Promise<void> {
export async function getMailboxAccounts(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
@@ -686,9 +898,10 @@ export async function syncMailboxStatus(req: AuthRequest, res: Response): Promis
}
// E-Mail-Thread abrufen
export async function getThread(req: Request, res: Response): Promise<void> {
export async function getThread(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const thread = await cachedEmailService.getEmailThread(id);
@@ -780,7 +993,7 @@ export async function getMailboxCredentials(req: AuthRequest, res: Response): Pr
}
// Ungelesene E-Mails zählen
export async function getUnreadCount(req: Request, res: Response): Promise<void> {
export async function getUnreadCount(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
@@ -788,8 +1001,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
let count = 0;
if (customerId) {
if (!(await canAccessCustomer(req, res, customerId))) return;
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
} else if (contractId) {
if (!(await canAccessContract(req, res, contractId))) return;
count = await cachedEmailService.getUnreadCountForContract(contractId);
}
@@ -804,9 +1019,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
}
// E-Mail in Papierkorb verschieben (nur Admin)
export async function deleteEmail(req: Request, res: Response): Promise<void> {
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
// Prüfen ob E-Mail existiert
const email = await cachedEmailService.getCachedEmailById(id);
@@ -840,12 +1056,25 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
// ==================== TRASH OPERATIONS ====================
// Papierkorb-E-Mails für einen Kunden abrufen
export async function getTrashEmails(req: Request, res: Response): Promise<void> {
// 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) {
@@ -857,12 +1086,20 @@ export async function getTrashEmails(req: Request, res: Response): Promise<void>
}
}
// Papierkorb-Anzahl für einen Kunden
export async function getTrashCount(req: Request, res: Response): Promise<void> {
// 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) {
@@ -875,9 +1112,10 @@ export async function getTrashCount(req: Request, res: Response): Promise<void>
}
// E-Mail aus Papierkorb wiederherstellen
export async function restoreEmail(req: Request, res: Response): Promise<void> {
export async function restoreEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const result = await cachedEmailService.restoreEmailFromTrash(id);
@@ -900,9 +1138,10 @@ export async function restoreEmail(req: Request, res: Response): Promise<void> {
}
// E-Mail endgültig löschen (aus Papierkorb)
export async function permanentDeleteEmail(req: Request, res: Response): Promise<void> {
export async function permanentDeleteEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const result = await cachedEmailService.permanentDeleteEmail(id);
@@ -927,9 +1166,10 @@ export async function permanentDeleteEmail(req: Request, res: Response): Promise
// ==================== ATTACHMENT TARGETS ====================
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
export async function getAttachmentTargets(req: Request, res: Response): Promise<void> {
export async function getAttachmentTargets(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail mit StressfreiEmail laden
const email = await cachedEmailService.getCachedEmailById(emailId);
@@ -1109,9 +1349,10 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
}
// E-Mail-Anhang in ein Dokumentenfeld speichern
export async function saveAttachmentTo(req: Request, res: Response): Promise<void> {
export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename);
const { entityType, entityId, targetKey } = req.body;
@@ -1235,6 +1476,9 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
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);
@@ -1385,8 +1629,9 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
} 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);
@@ -1396,9 +1641,10 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
// ==================== SAVE EMAIL AS PDF ====================
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> {
export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { entityType, entityId, targetKey } = req.body;
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
@@ -1643,9 +1889,10 @@ export async function saveEmailAsPdf(req: Request, res: Response): Promise<void>
// ==================== SAVE EMAIL AS INVOICE ====================
// E-Mail als PDF exportieren und als Rechnung speichern
export async function saveEmailAsInvoice(req: Request, res: Response): Promise<void> {
export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
@@ -1766,12 +2013,120 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
}
}
// ==================== SAVE EMAIL AS CONTRACT DOCUMENT ====================
// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen.
// Parallel zu saveAttachmentAsContractDocument (Anhang-Variante) damit
// auch reine Mail-Bestätigungen ohne Anhang als Auftragsformular/
// Lieferbestätigung etc. an einem Vertrag landen können.
export async function saveEmailAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { documentType, notes } = req.body;
// 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;
}
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
return;
}
if (!email.contractId) {
res.status(400).json({ success: false, error: 'E-Mail ist keinem Vertrag zugeordnet' } as ApiResponse);
return;
}
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
select: { id: true, contractNumber: true, customerId: true },
});
if (!contract) {
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
// Empfänger-Adressen parsen
let toAddresses: string[] = [];
let ccAddresses: string[] = [];
try { toAddresses = JSON.parse(email.toAddresses); } catch { toAddresses = [email.toAddresses]; }
try { if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses); } catch { /* ignore */ }
const pdfBuffer = await generateEmailPdf({
from: email.fromAddress,
to: toAddresses.join(', '),
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
subject: email.subject || '(Kein Betreff)',
date: email.receivedAt,
bodyText: email.textBody || undefined,
bodyHtml: email.htmlBody || undefined,
});
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
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);
return prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType: validatedType,
documentPath: relativePath,
originalName: `${email.subject || 'email'}.pdf`,
notes: sanitizeNotes(notes),
uploadedBy: (req as any).user?.email || 'email-import',
},
});
});
// 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(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
}
}
// ==================== SAVE ATTACHMENT AS INVOICE ====================
// E-Mail-Anhang als Rechnung speichern
export async function saveAttachmentAsInvoice(req: Request, res: Response): Promise<void> {
export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename);
const { invoiceDate, invoiceType, notes } = req.body;
@@ -1896,6 +2251,9 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
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);
@@ -1920,8 +2278,9 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
} 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);
@@ -1932,20 +2291,34 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
*/
export async function saveAttachmentAsContractDocument(req: Request, res: Response): Promise<void> {
export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
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);
@@ -2031,33 +2404,40 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon
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({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: filename,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
// 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: validatedType,
documentPath: relativePath,
originalName: filename,
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);
@@ -138,7 +138,13 @@ export async function grantAllConsents(req: Request, res: Response) {
}
}
res.json({ success: true, data: results });
// Minimal-Response: NUR die Anzahl + Status. Kein ipAddress, kein createdBy,
// keine internen IDs das war früher der volle CustomerConsent-Record und
// hat unnötig Daten geleakt (Pentest Runde 5, 2026-05-16).
res.json({
success: true,
data: { granted: results.length },
});
} catch (error: any) {
console.error('Fehler beim Erteilen der Einwilligungen:', error);
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
+269 -36
View File
@@ -1,13 +1,76 @@
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';
import * as contractHistoryService from '../services/contractHistory.service.js';
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, 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
* eines Body-Objekts (rekursiv über energyDetails, internetDetails etc.).
* Pentest 2026-05-24 (MEDIUM, 31.1): providerName, tariffName und die
* price*-Felder nahmen rohe HTML-Payloads an (`<script>`, `<svg onload>`)
* und lieferten sie 1:1 an Portal-User zurück. Verträge enthalten KEINE
* HTML-Felder (Richtige HTML-Texte liegen in AppSettings), deshalb ist
* Strip safe.
*
* AUSNAHME: Passwort-/Secret-Felder. `stripHtml` filtert `<…>`-Sequenzen
* und URI-Schemata wie `data:`, also würde ein PW wie `Pass<TAG>word!`
* zu `Password!` mutilieren oder `data:secret` zu `blocked:secret`.
* Das Passwort wird sowieso verschlüsselt persistiert (`encrypt()`),
* niemals als HTML ausgegeben also kein XSS-Risk, und die Mangling
* ist ein Bug (2026-05-27, intern gemeldet: "Portal-Passwörter werden
* nicht gespeichert").
*/
const PASSTHROUGH_KEYS = new Set(['portalPassword', 'password']);
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));
if (typeof body === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
out[k] = sanitizeContractBody(v, k);
}
return out;
}
return body;
}
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -46,9 +109,15 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractsStrict(result.contracts as any[])
: sanitizeContracts(result.contracts as any[]);
res.json({
success: true,
data: result.contracts,
data,
pagination: result.pagination,
} as ApiResponse);
} catch (error) {
@@ -89,7 +158,11 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
}
}
res.json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
@@ -98,16 +171,30 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
}
}
export async function createContract(req: Request, res: Response): Promise<void> {
export async function createContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contract = await contractService.createContract(req.body);
// Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
// Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
const body = (req.body || {}) as Record<string, unknown>;
if (!body.type || typeof body.type !== 'string') {
res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse);
return;
}
if (!body.customerId || typeof body.customerId !== 'number') {
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
return;
}
const sanitizedBody = sanitizeContractBody(body);
const contract = await contractService.createContract(sanitizedBody as any);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
resourceId: contract.id.toString(),
label: `Vertrag ${contract.contractNumber} angelegt`,
customerId: contract.customerId,
});
res.status(201).json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -119,19 +206,30 @@ export async function createContract(req: Request, res: Response): Promise<void>
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 },
include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true },
});
const contract = await contractService.updateContract(contractId, req.body);
// HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
const sanitizedBody = sanitizeContractBody(req.body);
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
// Geänderte Felder ermitteln
const changes: Record<string, { von: unknown; nach: unknown }> = {};
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',
@@ -140,11 +238,14 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
};
const energyLabels: Record<string, string> = {
meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis',
instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus',
};
// Hauptfelder vergleichen
const body = req.body;
// Hauptfelder vergleichen gegen die SANITISIERTE Version, damit
// das Audit-Log die echten DB-Werte widerspiegelt, nicht den
// rohen Request-Body mit ggf. gestrippter HTML.
const body = sanitizedBody as any;
if (before) {
for (const [key, newVal] of Object.entries(body)) {
if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue;
@@ -179,7 +280,13 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
customerId: before?.customerId,
});
res.json({ success: true, data: contract } as ApiResponse);
// Response sanitisieren sonst leakt portalPasswordEncrypted etc.
// (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -191,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({
@@ -211,6 +320,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
try {
const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
// Vorgängervertrag laden für Vertragsnummer
const previousContract = await prisma.contract.findUnique({
@@ -247,7 +357,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
customerId: contract.customerId,
});
res.status(201).json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -264,6 +376,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
try {
const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
const previousContract = await prisma.contract.findUnique({
where: { id: previousContractId },
@@ -299,7 +412,9 @@ export async function createRenewal(req: AuthRequest, res: Response): Promise<vo
customerId: contract.customerId,
});
res.status(201).json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -429,7 +544,22 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
try {
const cockpitData = await contractCockpitService.getCockpitData();
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
// (Pentest Runde 4, 2026-05-16: HOCH).
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerIds = [req.user.customerId];
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
}
}
}
const cockpitData = await contractCockpitService.getCockpitData({ customerIds });
res.json({ success: true, data: cockpitData } as ApiResponse);
} catch (error) {
console.error('Cockpit error:', error);
@@ -445,7 +575,7 @@ export async function getCockpit(req: AuthRequest, res: Response): Promise<void>
export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
const { meterId, installedAt, finalReadingPrevious } = req.body;
const { meterId, installedAt, finalReadingPrevious, deactivatePredecessor } = req.body;
const contract = await prisma.contract.findUnique({
where: { id: contractId },
@@ -458,19 +588,72 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
}
const ecdId = contract.energyDetails.id;
const existingMeters = contract.energyDetails.contractMeters;
const existingMeters = [...contract.energyDetails.contractMeters];
const switchAt = installedAt ? new Date(installedAt) : new Date();
// Vorgänger ermitteln (letzter ContractMeter oder Single-Meter-Vertrag)
const predecessorMeterId = existingMeters.length > 0
? existingMeters[existingMeters.length - 1].meterId
: contract.energyDetails.meterId;
// Endstand bereits hier validieren (monoton-steigend gegen vorhandene
// Zählerstände des Vorgängers), damit wir nicht halb-geschriebene
// Zustände hinterlassen.
if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
const finalReadingValue = parseFloat(finalReadingPrevious);
// recordPredecessorFinalReading läuft erst NACH den Writes Pre-Check
// ohne Write hier separat über die Service-Validierung (idempotent, weil
// sie keinen Reading anlegt, wenn am Wechseltag schon einer existiert).
// Wir lassen den eigentlichen Write am Ende laufen, damit ein Fehler
// beim Reading die Kette nicht zerreißt.
const dayStart = new Date(switchAt); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1);
const sameDay = await prisma.meterReading.findFirst({
where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } },
});
if (!sameDay) {
const lastBefore = await prisma.meterReading.findFirst({
where: { meterId: predecessorMeterId, readingDate: { lte: switchAt } },
orderBy: { readingDate: 'desc' },
});
if (lastBefore && finalReadingValue < lastBefore.value) {
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE');
throw new Error(`Endstand (${finalReadingValue}) darf nicht kleiner sein als der Stand vom ${fmtDate(lastBefore.readingDate)} (${lastBefore.value})`);
}
}
}
// Backfill: Bei Single-Meter-Verträgen (kein ContractMeter-Eintrag) den
// bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die
// Folgezähler-Kette lückenlos ist und der alte Zähler nicht aus dem
// Vertrag verschwindet, wenn `energyDetails.meterId` gleich auf den Neuen
// gedreht wird.
if (existingMeters.length === 0 && contract.energyDetails.meterId) {
const backfilled = await prisma.contractMeter.create({
data: {
energyContractDetailsId: ecdId,
meterId: contract.energyDetails.meterId,
position: 0,
installedAt: null,
},
});
existingMeters.push(backfilled);
}
const nextPosition = existingMeters.length > 0
? Math.max(...existingMeters.map(m => m.position)) + 1
: 0;
// Vorherigen Zähler als gewechselt markieren
if (existingMeters.length > 0 && finalReadingPrevious !== undefined) {
if (existingMeters.length > 0) {
const prevMeter = existingMeters[existingMeters.length - 1];
await prisma.contractMeter.update({
where: { id: prevMeter.id },
data: {
removedAt: installedAt ? new Date(installedAt) : new Date(),
finalReading: parseFloat(finalReadingPrevious),
removedAt: switchAt,
finalReading: finalReadingPrevious !== undefined
? parseFloat(finalReadingPrevious)
: prevMeter.finalReading,
},
});
}
@@ -480,7 +663,7 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
energyContractDetailsId: ecdId,
meterId: parseInt(meterId),
position: nextPosition,
installedAt: installedAt ? new Date(installedAt) : new Date(),
installedAt: switchAt,
},
include: { meter: { include: { readings: true } } },
});
@@ -491,6 +674,25 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
data: { meterId: parseInt(meterId) },
});
// Endstand des Vorgängers als regulären Zählerstand erfassen, damit er in
// die Verbrauchsberechnung einfließt und in der Zählerstände-Liste auftaucht.
if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
await recordPredecessorFinalReading(
predecessorMeterId,
switchAt,
parseFloat(finalReadingPrevious),
);
}
// Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht
// explizit auf false setzt ein-klick-fähiger Zählerwechsel.
if (predecessorMeterId && deactivatePredecessor !== false) {
await prisma.meter.update({
where: { id: predecessorMeterId },
data: { isActive: false },
});
}
await logChange({
req, action: 'CREATE', resourceType: 'ContractMeter',
resourceId: contractMeter.id.toString(),
@@ -511,6 +713,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
try {
const contractMeterId = parseInt(req.params.contractMeterId);
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
await logChange({
req, action: 'DELETE', resourceType: 'ContractMeter',
@@ -547,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);
@@ -559,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({
data: {
contractId,
documentType,
documentPath,
originalName: req.file.originalname,
notes: notes || null,
uploadedBy: req.user?.email,
},
});
// 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: cleanType,
documentPath,
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);
@@ -634,9 +866,10 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
export async function snoozeContract(req: Request, res: Response): Promise<void> {
export async function snoozeContract(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessContract(req, res, id))) return;
const { nextReviewDate, months } = req.body;
let reviewDate: Date | null = null;
@@ -2,10 +2,12 @@ import { Request, Response } from 'express';
import * as contractHistoryService from '../services/contractHistory.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract } from '../utils/accessControl.js';
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entries = await contractHistoryService.getHistoryEntries(contractId);
res.json({ success: true, data: entries } as ApiResponse);
} catch (error) {
@@ -19,6 +21,7 @@ export async function getHistoryEntries(req: AuthRequest, res: Response): Promis
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const { title, description } = req.body;
if (!title || typeof title !== 'string' || title.trim().length === 0) {
@@ -54,6 +57,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entryId = parseInt(req.params.entryId);
const { title, description } = req.body;
@@ -80,6 +84,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entryId = parseInt(req.params.entryId);
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
@@ -5,19 +5,30 @@ import * as customerService from '../services/customer.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
// ==================== ALL TASKS (Dashboard & Task List) ====================
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
try {
const { status, customerId } = req.query;
const customerIdNum = customerId ? parseInt(customerId as string) : undefined;
// Für Kundenportal: Filter auf erlaubte Kunden
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
const allowedIds = await getPortalAllowedCustomerIds(req);
let customerPortalCustomerIds: number[] | undefined;
let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (allowedIds) {
// Wenn der Portal-User explizit nach einer customerId filtert, die er
// nicht (mehr) vertreten darf → 403 statt 200 mit leerem Array
// (Pentest Runde 10 LOW: konsistentes Response-Verhalten nach
// Vollmacht-Widerruf).
if (customerIdNum !== undefined && !allowedIds.includes(customerIdNum)) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' } as ApiResponse);
return;
}
customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
@@ -26,7 +37,7 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
const tasks = await contractTaskService.getAllTasks({
status: status as 'OPEN' | 'COMPLETED' | undefined,
customerId: customerId ? parseInt(customerId as string) : undefined,
customerId: customerIdNum,
customerPortalCustomerIds,
customerPortalEmails,
});
@@ -42,12 +53,13 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
try {
// Für Kundenportal: Filter auf erlaubte Kunden
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
const allowedIds = await getPortalAllowedCustomerIds(req);
let customerPortalCustomerIds: number[] | undefined;
let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (allowedIds) {
customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
@@ -75,33 +87,17 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
const contractId = parseInt(req.params.contractId);
const { status } = req.query;
// Prüfe Zugriff auf den Vertrag
const contract = await contractService.getContractById(contractId);
if (!contract) {
res.status(404).json({
success: false,
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
// hasAuthorization (Pentest Runde 6 HOCH-04: widerrufene Vollmachten
// hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
// konsultiert wurde, ohne Status-Check).
if (!(await canAccessContract(req, res, contractId))) return;
// Für Kundenportal: Zugriffsprüfung
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (!allowedCustomerIds.includes(contract.customerId)) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diesen Vertrag',
} as ApiResponse);
return;
}
}
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden (mit Live-Vollmacht-Check)
let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedIds = await getPortalAllowedCustomerIds(req);
if (allowedIds) {
const customers = await customerService.getCustomersByIds(allowedIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
@@ -187,27 +183,8 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
return;
}
// Prüfe Zugriff auf den Vertrag
const contract = await contractService.getContractById(contractId);
if (!contract) {
res.status(404).json({
success: false,
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Zugriffsprüfung für Kundenportal
if (req.user?.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (!allowedCustomerIds.includes(contract.customerId)) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diesen Vertrag',
} as ApiResponse);
return;
}
}
// canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
if (!(await canAccessContract(req, res, contractId))) return;
const createdBy = req.user?.email;
@@ -376,24 +353,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
return;
}
// Prüfe ob der Kunde berechtigt ist (eigenes Ticket oder freigegebener Kunde)
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
// Task muss entweder visibleInPortal sein ODER vom Kunden erstellt worden sein
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
if (!task.visibleInPortal && !isOwnTask) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Anfrage',
} as ApiResponse);
return;
}
} else {
if (!req.user?.isCustomerPortal || !req.user.customerId) {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Benutzer',
@@ -401,6 +361,27 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
return;
}
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
// via hasAuthorization, Pentest Runde 6 HOCH-04). Damit kann ein
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
if (!(await canAccessContract(req, res, task.contractId))) return;
// Zusätzlich: portal-User darf nur antworten, wenn die Task von ihm
// initiiert wurde ODER explizit für ihn sichtbar markiert ist.
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
if (!task.visibleInPortal && !isOwnTask) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Anfrage',
} as ApiResponse);
return;
}
const createdBy = req.user?.email;
const subtask = await contractTaskService.createSubtask({
+249 -40
View File
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js';
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import {
sanitizeCustomer,
@@ -10,6 +11,8 @@ import {
sanitizeCustomerStrict,
pickCustomerCreate,
pickCustomerUpdate,
sanitizePhoneField,
isValidEmail,
} from '../utils/sanitize.js';
import {
canAccessMeter,
@@ -17,30 +20,28 @@ import {
canAccessBankCard,
canAccessIdentityDocument,
canAccessCustomer,
getPortalAllowedCustomerIds,
} from '../utils/accessControl.js';
// Customer CRUD
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
try {
const { search, type, page, limit } = req.query;
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit aktiver
// Vollmacht) sehen. Wir geben die Liste direkt als DB-Filter mit, damit
// auch `pagination.total` nur über diese IDs zählt (Pentest Runde 6
// MITTEL-02: `total: 4271` leakte vorher die globale Kunden-Zahl).
const allowedIds = await getPortalAllowedCustomerIds(req);
const result = await customerService.getAllCustomers({
search: search as string,
type: type as 'PRIVATE' | 'BUSINESS',
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
allowedIds: allowedIds ?? undefined,
});
let customers = result.customers as any[];
// Portal-Kunden: Liste auf eigenen + vertretene Kunden einschränken.
// Ohne diesen Filter würde der List-Endpoint die komplette Kundendatenbank
// an einen einzelnen Portal-Account preisgeben.
if (req.user?.isCustomerPortal) {
const allowedIds = new Set<number>();
if (req.user.customerId) allowedIds.add(req.user.customerId);
const represented = (req.user as any).representedCustomerIds || [];
for (const id of represented) allowedIds.add(id);
customers = customers.filter((c) => allowedIds.has(c.id));
}
const customers = result.customers as any[];
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
@@ -80,6 +81,30 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen
const data: any = pickCustomerCreate(req.body);
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
if (data.email && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
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);
@@ -91,7 +116,14 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
customerId: customer.id,
});
res.status(201).json({ success: true, data: customer } as ApiResponse);
// Response sanitisieren (Pentest Runde 15, 20.3/20.4): die Service-
// Funktion gibt das rohe DB-Objekt mit portalPasswordHash + Reset-Token
// zurück. Ohne sanitize-Aufruf leakte das beim Erstellen + Update.
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -104,8 +136,34 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
try {
const customerId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
if (req.body?.email && !isValidEmail(req.body.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
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 } });
@@ -170,7 +228,14 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
}
}
res.json({ success: true, data: customer } as ApiResponse);
// Response sanitisieren sonst leakt portalPasswordHash +
// portalPasswordResetToken + consentHash + portalPasswordMustChange.
// Pentest Runde 15 (20.3 KRITISCH, 20.4 HOCH).
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
console.error('Update customer error:', error);
res.status(400).json({
@@ -232,9 +297,10 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
}
}
export async function updateAddress(req: Request, res: Response): Promise<void> {
export async function updateAddress(req: AuthRequest, res: Response): Promise<void> {
try {
const addressId = parseInt(req.params.id);
if (!(await canAccessAddress(req, res, addressId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -295,9 +361,10 @@ export async function updateAddress(req: Request, res: Response): Promise<void>
}
}
export async function deleteAddress(req: Request, res: Response): Promise<void> {
export async function deleteAddress(req: AuthRequest, res: Response): Promise<void> {
try {
const addressId = parseInt(req.params.id);
if (!(await canAccessAddress(req, res, addressId))) return;
const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } });
const customerId = addr?.customerId;
await customerService.deleteAddress(addressId);
@@ -349,9 +416,10 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
}
}
export async function updateBankCard(req: Request, res: Response): Promise<void> {
export async function updateBankCard(req: AuthRequest, res: Response): Promise<void> {
try {
const cardId = parseInt(req.params.id);
if (!(await canAccessBankCard(req, res, cardId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -407,9 +475,10 @@ export async function updateBankCard(req: Request, res: Response): Promise<void>
}
}
export async function deleteBankCard(req: Request, res: Response): Promise<void> {
export async function deleteBankCard(req: AuthRequest, res: Response): Promise<void> {
try {
const cardId = parseInt(req.params.id);
if (!(await canAccessBankCard(req, res, cardId))) return;
const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } });
const customerId = card?.customerId;
await customerService.deleteBankCard(cardId);
@@ -461,9 +530,10 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
}
}
export async function updateDocument(req: Request, res: Response): Promise<void> {
export async function updateDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const docId = parseInt(req.params.id);
if (!(await canAccessIdentityDocument(req, res, docId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -525,9 +595,10 @@ export async function updateDocument(req: Request, res: Response): Promise<void>
}
}
export async function deleteDocument(req: Request, res: Response): Promise<void> {
export async function deleteDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const docId = parseInt(req.params.id);
if (!(await canAccessIdentityDocument(req, res, docId))) return;
const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } });
const customerId = doc?.customerId;
await customerService.deleteDocument(docId);
@@ -564,10 +635,13 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const meter = await customerService.createMeter(customerId, req.body);
const successorLabel = meter.predecessor
? ` als Folgezähler von ${meter.predecessor.meterNumber}`
: '';
await logChange({
req, action: 'CREATE', resourceType: 'Meter',
resourceId: meter.id.toString(),
label: `Zähler angelegt für Kunde #${customerId}`,
label: `Zähler angelegt${successorLabel} für Kunde #${customerId}`,
customerId,
});
res.status(201).json({ success: true, data: meter } as ApiResponse);
@@ -579,9 +653,10 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
}
}
export async function updateMeter(req: Request, res: Response): Promise<void> {
export async function updateMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.id);
if (!(await canAccessMeter(req, res, meterId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -595,7 +670,7 @@ export async function updateMeter(req: Request, res: Response): Promise<void> {
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
meterNumber: 'Zählernummer', type: 'Typ', tariffModel: 'Tarifmodell',
location: 'Standort', isActive: 'Aktiv',
location: 'Standort', isActive: 'Aktiv', addressId: 'Lieferadresse',
};
for (const [key, newVal] of Object.entries(data)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
@@ -636,9 +711,10 @@ export async function updateMeter(req: Request, res: Response): Promise<void> {
}
}
export async function deleteMeter(req: Request, res: Response): Promise<void> {
export async function deleteMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.id);
if (!(await canAccessMeter(req, res, meterId))) return;
await customerService.deleteMeter(meterId);
await logChange({
req, action: 'DELETE', resourceType: 'Meter',
@@ -666,10 +742,11 @@ export async function getMeterReadings(req: AuthRequest, res: Response): Promise
}
}
export async function addMeterReading(req: Request, res: Response): Promise<void> {
export async function addMeterReading(req: AuthRequest, res: Response): Promise<void> {
try {
const { readingDate, value, valueNt, unit, notes } = req.body;
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const reading = await customerService.addMeterReading(meterId, {
readingDate: new Date(readingDate),
value: parseFloat(value),
@@ -702,8 +779,10 @@ export async function addMeterReading(req: Request, res: Response): Promise<void
}
}
export async function updateMeterReading(req: Request, res: Response): Promise<void> {
export async function updateMeterReading(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const { readingDate, value, valueNt, unit, notes } = req.body;
const updateData: Record<string, unknown> = {};
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
@@ -713,7 +792,7 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
if (notes !== undefined) updateData.notes = notes;
const reading = await customerService.updateMeterReading(
parseInt(req.params.meterId),
meterId,
parseInt(req.params.readingId),
updateData as any
);
@@ -731,13 +810,12 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
}
}
export async function deleteMeterReading(req: Request, res: Response): Promise<void> {
export async function deleteMeterReading(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readingId = parseInt(req.params.readingId);
await customerService.deleteMeterReading(
parseInt(req.params.meterId),
readingId
);
await customerService.deleteMeterReading(meterId, readingId);
await logChange({
req, action: 'DELETE', resourceType: 'MeterReading',
resourceId: readingId.toString(),
@@ -838,6 +916,7 @@ export async function getMyMeters(req: AuthRequest, res: Response): Promise<void
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readingId = parseInt(req.params.readingId);
const reading = await prisma.meterReading.update({
@@ -897,7 +976,27 @@ export async function getPortalSettings(req: AuthRequest, res: Response): Promis
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const { portalEnabled, portalEmail } = req.body;
// `password` (oder password-ähnliche Felder) gehören NICHT in den
// Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt
// (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer
// ein Passwort setzen will, nutzt POST /portal/password mit
// Komplexitäts-Check. (Pentest-Befund.)
const body = req.body || {};
const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted'];
const offending = forbidden.filter((k) => k in body);
if (offending.length > 0) {
res.status(400).json({
success: false,
error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`,
} as ApiResponse);
return;
}
const { portalEnabled, portalEmail } = body;
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
if (portalEmail && !isValidEmail(portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({
@@ -957,13 +1056,115 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
}
}
/**
* Generiert ein zufälliges, komplexes Passwort (16 Zeichen, gemischt).
* Setzt es NICHT direkt — wird im Frontend in den Setzen-Button-Flow gefüttert.
* Damit hat der Admin Wahlfreiheit (Generieren → ggf. anpassen → speichern).
*/
export async function generatePortalPassword(req: Request, res: Response): Promise<void> {
try {
const password = generateSecurePassword({ length: 16 });
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
} as ApiResponse);
}
}
/**
* Verschickt die Portal-Zugangsdaten per E-Mail an die hinterlegte
* `email` (bevorzugt) oder fallback auf `portalEmail` des Kunden. Das
* Passwort wird aus dem `portalPasswordEncrypted`-Feld entschlüsselt
* (= das aktuell aktive Klartext-Passwort, das auch in der UI angezeigt wird).
*/
export async function sendPortalCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
id: true, firstName: true, lastName: true, salutation: true, companyName: true,
email: true, portalEmail: true, portalEnabled: true,
portalPasswordEncrypted: true, portalPasswordHash: true,
},
});
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
}
if (!customer.portalEnabled) {
res.status(400).json({
success: false,
error: 'Portal ist für diesen Kunden nicht aktiviert',
} as ApiResponse);
return;
}
if (!customer.portalPasswordHash) {
res.status(400).json({
success: false,
error: 'Es ist noch kein Portal-Passwort gesetzt',
} as ApiResponse);
return;
}
const targetEmail = customer.email || customer.portalEmail;
if (!targetEmail) {
res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
} as ApiResponse);
return;
}
const loginEmail = customer.portalEmail || customer.email!;
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
if (!plaintextPassword) {
res.status(400).json({
success: false,
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld bitte neu setzen)',
} as ApiResponse);
return;
}
await authService.sendPortalCredentialsEmail({
to: targetEmail,
customer,
loginEmail,
password: plaintextPassword,
});
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
// der Kunde sich ein eigenes setzen.
await authService.markPortalPasswordForChange(customerId);
await logChange({
req,
action: 'UPDATE',
resourceType: 'PortalSettings',
resourceId: customerId.toString(),
label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail} (Einmalpasswort)`,
customerId,
});
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
} as ApiResponse);
}
}
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
try {
const { password } = req.body;
if (!password || password.length < 6) {
// Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
@@ -984,9 +1185,10 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
}
}
export async function getPortalPassword(req: Request, res: Response): Promise<void> {
export async function getPortalPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const password = await authService.getCustomerPortalPassword(customerId);
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
@@ -1025,9 +1227,10 @@ export async function getRepresentatives(req: AuthRequest, res: Response): Promi
}
}
export async function addRepresentative(req: Request, res: Response): Promise<void> {
export async function addRepresentative(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const { representativeId, notes } = req.body;
const representative = await customerService.addRepresentative(
customerId,
@@ -1049,9 +1252,10 @@ export async function addRepresentative(req: Request, res: Response): Promise<vo
}
}
export async function removeRepresentative(req: Request, res: Response): Promise<void> {
export async function removeRepresentative(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
await customerService.removeRepresentative(
customerId,
parseInt(req.params.representativeId)
@@ -1070,8 +1274,13 @@ export async function removeRepresentative(req: Request, res: Response): Promise
}
}
export async function searchForRepresentative(req: Request, res: Response): Promise<void> {
export async function searchForRepresentative(req: AuthRequest, res: Response): Promise<void> {
try {
// KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User
// mit beliebigem :customerId-Pfad alle Kunden durchsuchen → komplette
// Kunden-DB-Enumeration via Buchstaben-Brute-Force.
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const { search } = req.query;
if (!search || typeof search !== 'string' || search.length < 2) {
res.json({ success: true, data: [] } as ApiResponse);
@@ -1079,7 +1288,7 @@ export async function searchForRepresentative(req: Request, res: Response): Prom
}
const customers = await customerService.searchCustomersForRepresentative(
search,
parseInt(req.params.customerId)
customerId,
);
res.json({ success: true, data: customers } as ApiResponse);
} catch (error) {
@@ -123,10 +123,15 @@ export async function testConnection(req: Request, res: Response): Promise<void>
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen ohne dass
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
// Pentest 50.1: strict=true test-connection darf NIE auf private IPs,
// Loopback oder Cloud-Metadata zeigen, unabhängig von
// SSRF_BLOCK_PRIVATE_IPS. On-Prem mit echtem internen Plesk kann das
// per SSRF_ALLOW_INTERNAL_TESTING=true opt-outen (Default: blockiert).
const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true';
if (testData?.apiUrl) {
try {
const url = new URL(testData.apiUrl);
await safeResolveHost(url.hostname, 'apiUrl-Host');
await safeResolveHost(url.hostname, 'apiUrl-Host', { strict: !allowInternalTesting });
} catch (err) {
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
const ctx = contextFromRequest(req);
@@ -247,12 +252,14 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
// ursprüngliche Hostname wird als TLS-servername gesetzt damit kann
// ein zweiter DNS-Lookup keine andere IP unterschieben.
// Pentest 50.1 analog testConnection: strict, opt-out via env.
const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true';
let smtpResolved: { ip: string; servername: string };
let imapResolved: { ip: string; servername: string };
try {
[smtpResolved, imapResolved] = await Promise.all([
safeResolveHost(smtpServer, 'SMTP-Server'),
safeResolveHost(imapServer, 'IMAP-Server'),
safeResolveHost(smtpServer, 'SMTP-Server', { strict: !allowInternalTesting }),
safeResolveHost(imapServer, 'IMAP-Server', { strict: !allowInternalTesting }),
]);
} catch (err) {
const ctx = contextFromRequest(req);
@@ -78,7 +78,81 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
return;
}
// Content-Type aus Extension bestimmen (konservativ Express macht das eh)
// Stored-XSS-Schutz (Pentest 2026-05-20 MEDIUM 30.13):
// Multer prüfte beim Upload nur den client-gemeldeten MIME-Type.
// Eine `.html`-Datei mit `Content-Type: application/pdf` rutschte
// durch und wurde mit Original-Extension auf Disk geschrieben.
// Beim Download bestimmt res.sendFile() den Content-Type aus der
// Extension also `text/html` und der Browser hätte das als
// Stored-XSS gerendert.
//
// Default: Content-Disposition: attachment → Browser lädt nur runter.
// 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';
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 (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 */ }
}
}
}
+161 -21
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';
@@ -13,6 +14,7 @@ import fs from 'fs';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import * as authorizationService from '../services/authorization.service.js';
import { stripHtml } from '../utils/sanitize.js';
/**
* Kundendaten exportieren (DSGVO Art. 15)
@@ -64,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({
@@ -269,7 +284,14 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const consentType = req.params.consentType as ConsentType;
const { status, source, documentPath, version } = req.body;
// BEWUSST nur `status` aus dem Body übernehmen. `source`, `documentPath`
// und `version` darf der Portal-User NICHT setzen Pentest 2026-05-20
// (MEDIUM): "ADMIN_OVERRIDE" als source bzw. "<script>" als version
// landeten vorher ungefiltert in der DB. source ist für diesen
// Endpoint immer 'portal'; documentPath wird ausschließlich vom
// Auth-Upload-Endpoint server-seitig gesetzt; version pflegt das CRM
// (falls überhaupt) später nach.
const { status } = req.body;
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
if (!(req.user as any)?.isCustomerPortal) {
@@ -279,22 +301,26 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
});
}
// Portal: nur eigene + vertretene Kunden
const allowed = [
(req.user as any).customerId,
...((req.user as any).representedCustomerIds || []),
];
if (!allowed.includes(customerId)) {
return res.status(403).json({
success: false,
error: 'Keine Berechtigung für diesen Kunden',
});
}
// canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
// widerrufene Vollmachten hatten vorher noch Zugriff)
if (!(await canAccessCustomer(req, res, customerId))) return;
if (!Object.values(ConsentType).includes(consentType)) {
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',
@@ -304,9 +330,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
const consent = await consentService.updateConsent(customerId, consentType, {
status,
source: source || 'portal',
documentPath,
version,
source: 'portal',
ipAddress: req.socket.remoteAddress,
createdBy: req.user?.email || 'unknown',
});
@@ -315,7 +339,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
await logChange({
req, action: 'UPDATE', resourceType: 'CustomerConsent',
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
details: { einwilligung: consentName, status, quelle: source || 'portal' },
details: { einwilligung: consentName, status, quelle: 'portal' },
customerId,
});
@@ -561,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
@@ -704,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
@@ -822,9 +852,15 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId);
const { source, notes } = req.body;
// Whitelist erzwingen, sonst landen Phantasie-Werte wie "ADMIN_OVERRIDE"
// oder `<script>` in der DB (Pentest 2026-05-20). notes wird durch
// stripHtml geschickt (Plain-Text-Feld).
const safeSource = consentService.sanitizeConsentSource(source, 'crm-backend');
const safeNotes = typeof notes === 'string' ? stripHtml(notes) : notes;
const auth = await authorizationService.grantAuthorization(customerId, representativeId, {
source: source || 'crm-backend',
notes,
source: safeSource,
notes: safeNotes as string | undefined,
});
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
@@ -887,6 +923,78 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
}
// Strukturelle PDF-Validierung: multer prüft nur den client-gemeldeten
// MIME-Type, ein Angreifer kann beliebige Daten als "application/pdf"
// hochladen. Wir verlangen:
// 1) Magic-Bytes "%PDF-" am Anfang
// 2) "%%EOF"-Marker in den letzten 1024 Bytes (Standard-PDF-Ende)
// 3) keinen Shebang ("#!") und kein "<script"/"<?php" in den
// ersten 4 KB (Pentest 28.3 Partial: "%PDF-1.4\n#!/bin/bash"
// passierte die reine Magic-Byte-Prüfung).
// Wer trotzdem eine PDF mit eingebettetem JS hochlädt, bekommt das
// hier nicht erkannt aber das ist Adobe-Acrobat-Risiko und nicht
// mehr ein CRM-Backend-Bug. Hier geht's um simple File-Type-Spoofs.
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
try {
const stat = fs.statSync(req.file.path);
const fd = fs.openSync(req.file.path, 'r');
// Header
const head = Buffer.alloc(5);
fs.readSync(fd, head, 0, 5, 0);
if (!head.equals(PDF_MAGIC)) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).',
});
}
// Erste 4 KB scannen auf verbotene Marker (Shell-Script,
// HTML/PHP-Payload). Ein echtes PDF enthält am Anfang nur
// Binärdaten + ein paar ASCII-Marker, "#!" / "<script" sind
// klare Spoof-Indikatoren.
const headSize = Math.min(stat.size, 4096);
const headBuf = Buffer.alloc(headSize);
fs.readSync(fd, headBuf, 0, headSize, 0);
const headStr = headBuf.toString('latin1').toLowerCase();
const forbidden = ['#!/', '<script', '<?php', '<%', 'mz']; // last = PE/Windows exe
const hit = forbidden.find((m) => headStr.includes(m));
if (hit) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: `Datei enthält verdächtiges Payload-Pattern ("${hit}").`,
});
}
// EOF-Marker in den letzten 1 KB. Strikt PDF/A wäre genau am
// Dateiende, aber viele Tools schreiben Whitespace/Newlines
// nach %%EOF, deshalb prüfen wir das letzte KB.
if (stat.size >= 5) {
const tailSize = Math.min(stat.size, 1024);
const tailBuf = Buffer.alloc(tailSize);
fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
if (!tailBuf.toString('latin1').includes('%%EOF')) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (EOF-Marker fehlt).',
});
}
}
fs.closeSync(fd);
} catch (_e) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Hochgeladene Datei konnte nicht gelesen werden.',
});
}
const documentPath = `/uploads/authorizations/${req.file.filename}`;
const auth = await authorizationService.updateAuthorizationDocument(
customerId,
@@ -1038,3 +1146,35 @@ export async function getMyAuthorizationStatus(req: AuthRequest, res: Response)
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Unterschreibbare Datenschutzerklärung (Papierform) als PDF generieren.
* Verwendung: Mitarbeiter klickt im Tab "Einwilligungen / Datenschutz"
* auf "Vorlage zum Unterschreiben", PDF kommt mit personalisiertem
* Kopf + Unterschriftsfeld zum Ausdrucken zurück.
*
* GET /api/gdpr/customer/:customerId/privacy-pdf
*/
export async function getSignablePrivacyPdf(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId, 10);
if (!Number.isFinite(customerId) || customerId < 1) {
return res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' });
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const pdf = await consentPublicService.generateSignablePrivacyPdf(customerId);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { customerNumber: true },
});
const filename = `datenschutzerklaerung-${customer?.customerNumber || customerId}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(pdf);
} catch (error) {
console.error('Fehler bei Datenschutz-PDF:', error);
res.status(500).json({ success: false, error: 'Fehler beim Generieren der PDF' });
}
}
@@ -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,
+80 -6
View File
@@ -1,7 +1,38 @@
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.js';
import * as providerService from '../services/provider.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
// Pentest 47.1 (MEDIUM, 2026-06-01): Open Redirect / Phishing via
// provider.portalUrl. Bei kompromittiertem Admin-Account konnte ein
// Angreifer einen Phishing-Link auf einem real existierenden Provider
// hinterlegen jeder Portal-User mit dem Provider sah ihn dauerhaft.
// Re-Auth-Pattern analog 47.3 (Staff-Password): bei Änderung der
// portalUrl-Domain muss der Admin sein eigenes Passwort mitsenden.
async function requireCallerPasswordReAuth(req: AuthRequest, providedPassword: unknown): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
const callerId = req.user?.userId;
if (!callerId) return { ok: false, status: 401, error: 'Nicht authentifiziert' };
if (typeof providedPassword !== 'string' || providedPassword.length === 0) {
return { ok: false, status: 400, error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.' };
}
const caller = await prisma.user.findUnique({ where: { id: callerId }, select: { password: true } });
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
const ok = await bcrypt.compare(providedPassword, callerHash);
if (!caller || !ok) return { ok: false, status: 403, error: 'Eigenes Passwort ist falsch.' };
return { ok: true };
}
// Pentest 49.1 (LOW, 2026-06-01): nur Host-Vergleich ließ Pfad-Änderungen
// am gleichen Host (z.B. `https://1und1.de/neue/pfad`) ohne Re-Auth
// durchgehen ein gestohlener JWT konnte Phishing-Pfade auf trusted
// Domains plazieren. Jetzt vergleichen wir die komplette normalisierte
// URL (Trailing-Slash, Whitespace).
function normalizeUrlForCompare(url: string | null | undefined): string {
return (url ?? '').trim().replace(/\/+$/, '').toLowerCase();
}
export async function getProviders(req: Request, res: Response): Promise<void> {
try {
@@ -18,7 +49,20 @@ export async function getProviders(req: Request, res: Response): Promise<void> {
export async function getProvider(req: Request, res: Response): Promise<void> {
try {
const provider = await providerService.getProviderById(parseInt(req.params.id));
// `req.params.id` ist Pfad-Segment bei /api/providers/email landet
// hier der String "email", den parseInt zu NaN macht. Ohne Validierung
// fuhr Prisma dann gegen `WHERE id = NaN` und warf 500.
// Pentest 2026-05-20, 29.5: explizit 404 statt 500. Andere Sub-Routes
// wie /api/providers/<id>/tariffs greifen weiter wie gehabt.
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
res.status(404).json({
success: false,
error: 'Anbieter nicht gefunden',
} as ApiResponse);
return;
}
const provider = await providerService.getProviderById(id);
if (!provider) {
res.status(404).json({
success: false,
@@ -37,11 +81,21 @@ export async function getProvider(req: Request, res: Response): Promise<void> {
export async function createProvider(req: Request, res: Response): Promise<void> {
try {
const provider = await providerService.createProvider(req.body);
const { currentPassword, ...providerData } = req.body || {};
// 47.1: Beim Create mit portalUrl ist Re-Auth Pflicht. Ohne portalUrl
// (rein interner Provider-Stammdatensatz) kein Zwang.
if (providerData.portalUrl) {
const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword);
if (!reauth.ok) {
res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse);
return;
}
}
const provider = await providerService.createProvider(providerData);
await logChange({
req, action: 'CREATE', resourceType: 'Provider',
resourceId: provider.id.toString(),
label: `Anbieter ${provider.name} angelegt`,
label: `Anbieter ${provider.name} angelegt${provider.portalUrl ? ` mit Portal-URL ${provider.portalUrl}` : ''}`,
});
res.status(201).json({ success: true, data: provider } as ApiResponse);
} catch (error) {
@@ -54,11 +108,31 @@ export async function createProvider(req: Request, res: Response): Promise<void>
export async function updateProvider(req: Request, res: Response): Promise<void> {
try {
const provider = await providerService.updateProvider(parseInt(req.params.id), req.body);
const providerId = parseInt(req.params.id);
const { currentPassword, ...providerData } = req.body || {};
// 47.1 + 49.1: jede portalUrl-Änderung braucht Re-Auth inkl. reiner
// Pfad-Änderungen am gleichen Host (Phishing-Pfade auf trusted Domain).
// Reine Namens-/Tarif-Edits bleiben friction-frei.
if (providerData.portalUrl !== undefined) {
const before = await providerService.getProviderById(providerId);
const isUrlChange = normalizeUrlForCompare(before?.portalUrl) !== normalizeUrlForCompare(providerData.portalUrl);
if (isUrlChange) {
const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword);
if (!reauth.ok) {
res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse);
return;
}
}
}
const provider = await providerService.updateProvider(providerId, providerData);
await logChange({
req, action: 'UPDATE', resourceType: 'Provider',
resourceId: provider.id.toString(),
label: `Anbieter ${provider.name} aktualisiert`,
label: providerData.portalUrl !== undefined
? `Anbieter ${provider.name} aktualisiert (Portal-URL: ${provider.portalUrl ?? 'entfernt'})`
: `Anbieter ${provider.name} aktualisiert`,
});
res.json({ success: true, data: provider } as ApiResponse);
} catch (error) {
@@ -0,0 +1,155 @@
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
import { logChange } from '../services/audit.service.js';
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
type ActiveLock = {
ipAddress: string;
email: string | null; // null = Passwort-Reset oder Login ohne Email
lastHit: Date;
hitCount: number;
lastEndpoint: string | null;
limiters: string[]; // 'login' / 'password-reset'
};
function lockKey(ip: string, email: string | null): string {
return `${ip}|${(email || '').toLowerCase()}`;
}
/**
* Listet aktive Sperren als (IP, Email)-Tupel. Jedes Tupel ist ein eigener
* Bucket im Limiter Reset gilt exakt für dieses Paar.
*/
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
try {
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
const events = await prisma.securityEvent.findMany({
where: { type: 'RATE_LIMIT_HIT', createdAt: { gte: since } },
orderBy: { createdAt: 'desc' },
select: {
ipAddress: true,
userEmail: true,
endpoint: true,
createdAt: true,
details: true,
},
});
const byKey = new Map<string, ActiveLock>();
for (const ev of events) {
const ip = ev.ipAddress || 'unknown';
const email = (ev.userEmail || '').toLowerCase() || null;
const limiter = (ev.details as any)?.limiter ?? 'unknown';
const key = lockKey(ip, email);
const existing = byKey.get(key);
if (existing) {
existing.hitCount += 1;
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
} else {
byKey.set(key, {
ipAddress: ip,
email,
lastHit: ev.createdAt,
hitCount: 1,
lastEndpoint: ev.endpoint,
limiters: [limiter],
});
}
}
// Bereits manuell freigegebene aus der Liste werfen. Reset-Audit-Logs
// nutzen resourceId = "<ip>|<email>" (gleicher Schlüssel wie Bucket).
const candidates = Array.from(byKey.entries()).map(([k, e]) => ({
mapKey: k,
resourceId: k,
lastHit: e.lastHit,
}));
if (candidates.length > 0) {
const recentResets = await prisma.auditLog.findMany({
where: {
resourceType: 'RateLimit',
resourceId: { in: candidates.map((c) => c.resourceId) },
createdAt: { gte: since },
},
select: { resourceId: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
const resetMap = new Map<string, Date>();
for (const r of recentResets) {
if (r.resourceId && !resetMap.has(r.resourceId)) resetMap.set(r.resourceId, r.createdAt);
}
for (const c of candidates) {
const reset = resetMap.get(c.resourceId);
if (reset && reset >= c.lastHit) byKey.delete(c.mapKey);
}
}
const list = Array.from(byKey.values()).sort(
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
);
res.json({ success: true, data: list } as ApiResponse);
} catch (error) {
console.error('getActiveRateLimits error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden der aktiven Rate-Limits',
} as ApiResponse);
}
}
/**
* Reset für ein konkretes (IP, Email)-Tupel. Body MUSS ipAddress enthalten
* + optional email. Bei fehlender Email wird `<ip>|<no-email>` reseted
* (für Login-Versuche mit leerem Body). Für Passwort-Reset-Limit wird der
* IP-only-Key (alter Stil) zusätzlich reseted.
*/
export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
try {
const ip = (req.body?.ipAddress || '').toString().trim();
const email = (req.body?.email || '').toString().trim().toLowerCase();
if (!ip) {
res.status(400).json({
success: false,
error: 'IP-Adresse erforderlich',
} as ApiResponse);
return;
}
// Login-Tuple-Bucket: `${ip}|${email}` bzw. `${ip}|<no-email>`
const loginKey = email ? `${ip}|${email}` : `${ip}|<no-email>`;
await (loginRateLimiter as any).resetKey?.(loginKey);
// Passwort-Reset-Limit ist (noch) IP-only auch zurücksetzen
await (passwordResetRateLimiter as any).resetKey?.(ip);
// Audit-Resource-ID = der Bucket-Key, damit getActiveRateLimits den
// Eintrag aus der Anzeige filtern kann.
const audited = `${ip}|${email || ''}`;
await logChange({
req,
action: 'UPDATE',
resourceType: 'RateLimit',
resourceId: audited,
label: email
? `Rate-Limit für (IP ${ip}, Email ${email}) manuell freigegeben`
: `Rate-Limit für IP ${ip} manuell freigegeben`,
});
res.json({
success: true,
message: email
? `Rate-Limit für (${ip}, ${email}) freigegeben`
: `Rate-Limit für ${ip} freigegeben`,
} as ApiResponse);
} catch (error) {
console.error('resetRateLimit error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Zurücksetzen des Rate-Limits',
} as ApiResponse);
}
}
@@ -2,11 +2,40 @@ import { Request, Response } from 'express';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessStressfreiEmail } from '../utils/accessControl.js';
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
import { ApiError } from '../utils/apiError.js';
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
// 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
// (MEDIUM 31.2) IDOR auf fremde IMAP-Konten. Hier daher der
// explizite Per-Customer-Check analog zum POST-Handler.
if (!(await canAccessCustomer(req, res, customerId))) return;
const includeInactive = req.query.includeInactive === 'true';
const emails = await stressfreiEmailService.getEmailsByCustomerId(customerId, includeInactive);
res.json({ success: true, data: emails } as ApiResponse);
@@ -20,7 +49,8 @@ export async function getEmailsByCustomer(req: Request, res: Response): Promise<
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);
@@ -48,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,
@@ -61,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);
@@ -70,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({
@@ -80,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);
@@ -89,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({
@@ -108,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);
@@ -145,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) {
+164 -4
View File
@@ -1,9 +1,11 @@
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Users
export async function getUsers(req: Request, res: Response): Promise<void> {
@@ -51,7 +53,38 @@ export async function getUser(req: Request, res: Response): Promise<void> {
export async function createUser(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const user = await userService.createUser(pickUserCreate(req.body) as any);
const data = pickUserCreate(req.body) as any;
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
// (Pentest 29.4 SMTP-Header-Injection).
if (!isValidEmail(data?.email) || !data?.email) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (data?.password) {
const c = validatePasswordComplexity(data.password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} 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;
}
const user = await userService.createUser(data);
await logChange({
req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(),
@@ -69,8 +102,53 @@ export async function createUser(req: Request, res: Response): Promise<void> {
export async function updateUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
// `permissions` und `password` darf der generische Update nicht
// entgegennehmen. Vorher landeten sie auf dem Floor (Whitelist-Drop),
// der Caller bekam aber 200 zurück und glaubte fälschlich, die Werte
// wären übernommen worden. Stattdessen sofort 400, damit Tooling /
// Client den Fehler sieht. (Pentest 2026-05-20)
// - permissions kommen aus Rollen (PUT roleIds bzw. die DSGVO-/
// Developer-Checkboxen) und können nicht direkt am User hängen.
// - password wird über POST /users/:id/password gesetzt
// (eigene Komplexitäts-Validierung + Audit-Trail).
const body = req.body || {};
const forbidden = ['permissions', 'password', 'passwordHash'];
const offending = forbidden.filter((k) => k in body);
if (offending.length > 0) {
res.status(400).json({
success: false,
error: `Felder nicht erlaubt: ${offending.join(', ')}. ` +
(offending.includes('permissions')
? 'Permissions werden über roleIds / hasGdprAccess / hasDeveloperAccess gesteuert. '
: '') +
(offending.includes('password') || offending.includes('passwordHash')
? `Passwort über POST /users/${userId}/password setzen.`
: ''),
} as ApiResponse);
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.
@@ -133,6 +211,88 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
}
}
// Admin setzt das Passwort eines anderen Users zurück. Separat vom
// generischen Update damit der Vorgang explizit auditiert wird und nicht
// versehentlich über Mass-Assignment passieren kann.
// Pentest Runde 12 (2026-05-18) MITTEL.
export async function setUserPassword(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
const { password, currentPassword } = req.body || {};
if (!password || typeof password !== 'string') {
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
return;
}
// Pentest 47.3 (MEDIUM, 2026-06-01): Re-Auth verpflichtend.
// Ein gestohlener Admin-JWT reichte bisher, um Staff-Passwörter
// umzuschreiben. Jetzt muss der aufrufende Admin sein eigenes
// Passwort mitsenden CSRF/Token-Klau allein reicht nicht mehr.
const authReq = req as AuthRequest;
const callerId = authReq.user?.userId;
if (!callerId) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
if (!currentPassword || typeof currentPassword !== 'string') {
res.status(400).json({
success: false,
error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.',
} as ApiResponse);
return;
}
const caller = await prisma.user.findUnique({
where: { id: callerId },
select: { password: true },
});
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
const reAuthOk = await bcrypt.compare(currentPassword, callerHash);
if (!caller || !reAuthOk) {
res.status(403).json({
success: false,
error: 'Eigenes Passwort ist falsch.',
} as ApiResponse);
return;
}
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
const user = await userService.updateUser(userId, { password } as any);
if (!user) {
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
return;
}
// Pentest 48.4 (INFO, 2026-06-01): Bestehende JWTs für den Ziel-User
// sofort invalidieren. Greift insbesondere bei Self-Reset (Admin setzt
// sich selbst zurück) der gestohlene Token wird damit ungültig,
// statt noch bis zum natürlichen Ablauf brauchbar zu bleiben.
// Die Middleware-Auth liest tokenInvalidatedAt und vergleicht gegen
// den `iat`-Claim des JWT.
await prisma.user.update({
where: { id: user.id },
data: { tokenInvalidatedAt: new Date() },
});
await logChange({
req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(),
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt (Re-Auth bestätigt, Sessions invalidiert)`,
});
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
} as ApiResponse);
}
}
export async function deleteUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
+136 -14
View File
@@ -84,16 +84,25 @@ if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
const app = express();
const PORT = process.env.PORT || 3001;
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
// auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen,
// während gleichzeitig der lokale Reverse-Proxy die echte Client-IP liefern darf.
// Trust-Proxy-Konfiguration für `req.ip` und `X-Forwarded-For`.
//
// WICHTIG für Production: Backend nur auf 127.0.0.1 lauschen lassen
// (LISTEN_ADDR=127.0.0.1) sonst kann ein direkter Connect von außen
// trotzdem als loopback gelten, falls das Routing das so durchstellt.
app.set('trust proxy', 'loopback');
// Zwei Szenarien:
// 1) **HTTPS_ENABLED=true** (Produktion mit vorgelagertem TLS-Proxy auf
// EIGENER Box, z.B. Nginx Proxy Manager): `trust proxy = 1` vertraut
// genau einem Hop → req.ip = echter Client (nicht der Proxy).
// Voraussetzung: Backend ist NICHT direkt aus dem Internet erreichbar,
// sonst könnte ein Direkt-Connect X-Forwarded-For faken und den
// Rate-Limiter / Security-Monitor umgehen. Bei NPM-Setup ist das
// durch das Docker-Network + nicht-veröffentlichten Backend-Port
// gewährleistet.
// 2) **HTTPS_ENABLED=false** (lokales Dev oder direkter http://ip:port-
// Zugriff): `loopback` reicht kein vertrauenswürdiger Hop davor.
//
// Vor dem Fix stand das auf `'loopback'` was im Produktiv-NPM-Setup
// IMMER die Proxy-IP statt der Client-IP lieferte → Rate-Limit und
// IDOR-Threshold-Detection sahen alle Angriffe als von „einem" Client.
const trustProxyValue = process.env.HTTPS_ENABLED === 'true' ? 1 : 'loopback';
app.set('trust proxy', trustProxyValue);
// ==================== SECURITY MIDDLEWARE ====================
@@ -244,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
@@ -253,6 +273,76 @@ app.use('/api', (_req, res, next) => {
next();
});
// Globaler Sanitizer für Fehler-Antworten: bekannte ORM-/Stack-Trace-Muster
// in `error`/`details`-Strings ersetzen, bevor sie an den Client gehen.
// So leakten frühere Builds bei z.B. `PUT /api/users/99999` rohe
// Prisma-Internals wie "Invalid `prisma.user.update()` invocation:
// Record to update not found" (Pentest Runde 11 M3). Der Original-Text
// landet weiterhin im Server-Log.
const ORM_LEAK_PATTERNS: RegExp[] = [
/Invalid `prisma\./i,
/PrismaClient/i,
/^\s*at\s+[A-Za-z]+\s+\(/m, // Stack-Frame
/at\s+[A-Za-z][\w.<>]*\s*\([^)]*:\d+:\d+\)/, // file:line:col
// JS-Runtime-Fehler Pentest Runde 12 (2026-05-18): "Cannot read
// properties of undefined (reading 'substring')" leakte aus POST
// /contracts. Solche Texte verraten Implementierungs-Details.
/^TypeError\b/i,
/^ReferenceError\b/i,
/^SyntaxError\b/i,
/^RangeError\b/i,
/Cannot read propert(y|ies) of (undefined|null)/i,
/is not a function/i,
/is not defined$/i,
];
function sanitizeErrorString(s: string): string {
if (!s) return s;
for (const re of ORM_LEAK_PATTERNS) {
if (re.test(s)) {
console.error('[orm-leak-guard] Maskierte Fehlermeldung:', s.slice(0, 300));
return 'Operation fehlgeschlagen';
}
}
return s;
}
app.use('/api', (_req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: any) => {
if (body && typeof body === 'object') {
if (typeof body.error === 'string') {
body.error = sanitizeErrorString(body.error);
}
if (typeof body.details === 'string') {
body.details = sanitizeErrorString(body.details);
}
}
return originalJson(body);
};
next();
});
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
// wurde kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
//
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$`
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
// nicht geblockt.
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
app.use('/api', (req, res, next) => {
for (const seg of req.path.split('/')) {
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
return;
}
}
next();
});
// Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes);
@@ -288,8 +378,13 @@ app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);
// Health check
app.get('/api/health', (req, res) => {
// Health check BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
// "ok" + Timestamp, keine Version, kein DB-Status, kein Hostname damit
// auch unauth Caller keine internen Infos einsammeln können. Pentest
// 2026-05-20 (INFO): kein Auth → akzeptiert, Antwort liefert nichts
// Sensibles.
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
@@ -338,9 +433,36 @@ if (process.env.NODE_ENV === 'production') {
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
// kaschiert und landen als "Interner Serverfehler" beim User.
app.use((err: Error & { status?: number; type?: string }, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
//
// Multer-Errors werden hier sauber abgefangen statt als 500 zu enden:
// - MulterError (z.B. LIMIT_FILE_SIZE) → 413
// - LIMIT_UNEXPECTED_FILE / -PART_COUNT etc. → 400
// - fileFilter-Errors (unzulässiger Typ) → 415
// Pentest 2026-05-30, INFO: WebP/GIF-Uploads (und alle anderen vom
// fileFilter abgelehnten Typen) lieferten vorher 500 mit Stack-Trace.
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err?.stack || err);
// Multer-spezifische Fehler (importiert as namespace)
if (err?.name === 'MulterError') {
const code = err.code;
if (code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ success: false, error: 'Datei ist zu groß' });
}
return res.status(400).json({
success: false,
error: 'Upload-Fehler: ' + (err.message || code || 'unbekannt'),
});
}
// fileFilter hat cb(new Error(...), false) gerufen kein MulterError,
// sondern unsere eigene Reject-Nachricht ("Nur PDF, JPG, ... erlaubt").
const msg = typeof err?.message === 'string' ? err.message : '';
if (/sind erlaubt|nicht erlaubt/i.test(msg)) {
return res.status(415).json({ success: false, error: msg });
}
const status = typeof err?.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
let message = 'Interner Serverfehler';
if (status === 413) message = 'Anfrage zu groß';
else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
+16
View File
@@ -1,6 +1,22 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { setBeforeValues, setAfterValues } from '../middleware/auditContext.js';
// DATABASE_URL aus DB_*-Komponenten bauen, falls nicht explizit gesetzt.
// Der entrypoint.sh macht das ebenfalls (für den Server-Start). Aber bei
// `docker exec opencrm-app npx tsx prisma/<script>.ts` läuft eine neue
// Shell ohne diese exportierte Variable die DB_*-Vars sind aus dem
// docker-compose.yml vererbt, DATABASE_URL aber nicht. Damit alle
// Wartungsskripte (reset-admin-password, cleanup-xss-...) und Server
// dieselbe Logik nutzen, machen wir es einmal zentral hier.
if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) {
const u = encodeURIComponent(process.env.DB_USER);
const p = encodeURIComponent(process.env.DB_PASSWORD);
const h = process.env.DB_HOST || 'db';
const port = process.env.DB_PORT || '3306';
const n = process.env.DB_NAME;
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${n}`;
}
// Modelle die für Before/After-Tracking relevant sind
const AUDITED_MODELS = [
'Customer',
+42 -14
View File
@@ -13,12 +13,15 @@ export async function authenticate(
// Token aus Header oder Query-Parameter (für Downloads)
let token: string | null = null;
let tokenSource: 'header' | 'query' | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1];
tokenSource = 'header';
} else if (req.query.token && typeof req.query.token === 'string') {
// Fallback für Downloads: Token als Query-Parameter
token = req.query.token;
tokenSource = 'query';
}
if (!token) {
@@ -38,7 +41,19 @@ export async function authenticate(
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
// zwangsabgemeldet werden.
if (decoded.type && decoded.type !== 'access') {
if (decoded.type === 'refresh') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
// Download-Tokens sind kurzlebig (60s) und dürfen NUR per `?token=`
// genutzt werden, NIE als Bearer-Header. Damit kann ein in einer URL
// geleakter Download-Token nicht für reguläre API-Aufrufe missbraucht
// werden (Pentest Runde 7 NIEDRIG, Token-in-URL-Defense).
if (decoded.type === 'download' && tokenSource !== 'query') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
if (decoded.type && decoded.type !== 'access' && decoded.type !== 'download') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
@@ -143,9 +158,34 @@ export function requireCustomerAccess(
return;
}
// WICHTIG: erst die isCustomerPortal-Prüfung, DANN erst die Perm-Prüfung.
// Portal-User bekommen `customers:read` im JWT (für eigene Daten); ohne
// den Portal-Check vorne weg short-circuited die alte Logik auf der
// Perm und ließ Portal-User auf fremde customerId zugreifen.
// Pentest 2026-05-24 (MEDIUM 31.2 IDOR auf /api/customers/:id/
// stressfrei-emails). Auch andere Routes mit dem gleichen Middleware-
// Pattern wären betroffen gewesen.
const userPermissions = req.user.permissions || [];
const isPortal = !!(req.user as any).isCustomerPortal;
const customerId = parseInt(req.params.customerId || req.params.id);
// Admins and employees can access all customers
if (isPortal) {
const allowedIds = [
req.user.customerId,
...((req.user as any).representedCustomerIds || []),
].filter(Boolean);
if (allowedIds.includes(customerId)) {
next();
return;
}
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Kundendaten',
});
return;
}
// Mitarbeiter/Admin: customers:read oder customers:update reicht
if (
userPermissions.includes('customers:read') ||
userPermissions.includes('customers:update')
@@ -154,18 +194,6 @@ export function requireCustomerAccess(
return;
}
// Customers can only access their own data + represented customers
const customerId = parseInt(req.params.customerId || req.params.id);
const allowedIds = [
req.user.customerId,
...((req.user as any).representedCustomerIds || []),
].filter(Boolean);
if (allowedIds.includes(customerId)) {
next();
return;
}
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Kundendaten',
+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 });
}
}
+82 -6
View File
@@ -25,20 +25,38 @@ function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
}
/**
* Login: 10 Versuche pro 15 Minuten pro IP.
* Nach Überschreitung: 15 Min Sperre für diese IP.
* Login-Limiter: 10 Fehlversuche pro 15 min PRO (IP + Email)-Tuple.
*
* Das Bucket ist gezielt das Paar, nicht IP allein und nicht Email allein:
* - IP allein wäre kein Schutz: ein Angreifer wechselt Proxy, hat wieder
* 10 freie Versuche gegen den gleichen Account.
* - Email allein erzeugt False-Positives (Familie hinter NAT: Max
* vertippt sich → Nina kommt von gleicher IP nicht mehr rein) und
* macht Account-Lockout-DoS möglich (Angreifer sperrt fremde Accounts
* aus, indem er von beliebigen IPs falsche PWs gegen sie probiert).
* - Tuple (IP, Email): Max kann sich nicht mehr einloggen, Nina von
* gleicher IP schon. Max von einer anderen IP auch, solange er das
* richtige PW hat ihre eigene Spur in den Buckets ist sauber.
*
* keyGenerator → `${ip}|${email-lowercase}`. Bei fehlender Email
* (z.B. komplett leerer Body) Fallback nur auf IP, damit kein
* Single-Shared-Bucket entsteht.
*/
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 Minuten
limit: 10, // Max. 10 Versuche pro Zeitfenster
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Login-Versuche. Bitte in 15 Minuten erneut versuchen.',
error: 'Zu viele Login-Versuche für diese Kombination aus Account und IP. Bitte in 15 Minuten erneut versuchen.',
},
// Erfolgreiche Logins zählen nicht gegen das Limit
skipSuccessfulRequests: true,
keyGenerator: (req): string => {
const email = (req.body?.email || '').toString().trim().toLowerCase();
const ip = req.ip || 'unknown';
return email ? `${ip}|${email}` : `${ip}|<no-email>`;
},
handler: (req, res, _next, options) => {
onLimitReached('login', 'HIGH')(req, res);
res.status(options.statusCode).json(options.message);
@@ -63,3 +81,61 @@ export const passwordResetRateLimiter = rateLimit({
res.status(options.statusCode).json(options.message);
},
});
/**
* Staff-Password-Set-Limiter (Pentest 48.3, 2026-06-01):
* POST /api/users/:id/password verlangt seit 47.3 die Eingabe des eigenen
* Admin-Passworts (`currentPassword`). Ohne Throttle könnte ein Angreifer
* mit gestohlenem JWT die 25-Zeichen-Passwort-Policy zwar nicht erraten,
* aber kürzere/typische Admin-Passwörter (z.B. Stagings, kompromittierte
* Setups) per Brute-Force durchprobieren und damit den Re-Auth-Fix
* komplett aushebeln.
*
* Bucket: (IP, target-user-id). Damit walked ein Angreifer pro Opfer
* langsam und kann nicht mit einem stolen-token gegen alle Staff-User
* parallel anrennen. `skipSuccessfulRequests: true`, weil legitime
* Passwort-Resets nicht den Counter füllen sollen.
*/
export const staffPasswordReAuthLimiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 Minuten
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele fehlgeschlagene Passwort-Set-Versuche. Bitte in 10 Minuten erneut versuchen.',
},
skipSuccessfulRequests: true,
keyGenerator: (req): string => {
const ip = req.ip || 'unknown';
const targetUserId = (req.params?.id ?? '<missing>').toString();
return `${ip}|staff-pw|${targetUserId}`;
},
handler: (req, res, _next, options) => {
onLimitReached('staff-password-set', 'HIGH')(req, res);
res.status(options.statusCode).json(options.message);
},
});
/**
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
* unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk,
* aber DoS-Vektor: ohne Limit könnte ein Angreifer endlos POSTen und
* den Service durch Audit-Log-Spam + Mail-Versand belasten.
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
* für legitime Kunden weit aus.
*/
export const publicConsentRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 30,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Anfragen. Bitte in 15 Minuten erneut versuchen.',
},
handler: (req, res, _next, options) => {
onLimitReached('public-consent', 'MEDIUM')(req, res);
res.status(options.statusCode).json(options.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' });
}
}
+29
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import multer from 'multer';
import * as appSettingController from '../controllers/appSetting.controller.js';
import * as backupController from '../controllers/backup.controller.js';
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
// Multer für Backup-Upload (in Memory speichern)
@@ -100,4 +101,32 @@ router.post(
backupController.factoryReset
);
// Backup-Operations-Log: Liste (ohne fullLog) + Detail
router.get(
'/backup-logs',
authenticate,
requirePermission('settings:update'),
backupController.listBackupLogs
);
router.get(
'/backup-logs/:id',
authenticate,
requirePermission('settings:update'),
backupController.getBackupLogDetail
);
// Rate-Limit-Verwaltung (Admin)
router.get(
'/rate-limits/active',
authenticate,
requirePermission('settings:read'),
rateLimitAdminController.getActiveRateLimits,
);
router.post(
'/rate-limits/reset',
authenticate,
requirePermission('settings:update'),
rateLimitAdminController.resetRateLimit,
);
export default router;
+11
View File
@@ -5,6 +5,11 @@ import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLi
const router = Router();
// loginRateLimiter sperrt pro (IP + Email)-Tuple. Damit kann sich
// `nina` von derselben IP einloggen, auch wenn `max` dort gerade
// 10x vergeigt hat und umgekehrt darf `max` von einer anderen IP
// auch dann noch versuchen, wenn IP-A gerade sein Bucket verbrannt
// hat (Pentest 2026-05-18 Szenario).
router.post('/login', loginRateLimiter, authController.login);
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/refresh', authController.refresh);
@@ -16,4 +21,10 @@ router.post('/register', authenticate, requirePermission('users:create'), authCo
router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset);
router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset);
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
// Kurzlebiger Download-Token (60s) für ?token=-Aufrufe (PDF/Export-Window)
router.post('/download-token', authenticate, authController.createDownloadToken);
export default router;
+14 -1
View File
@@ -194,6 +194,15 @@ router.post(
cachedEmailController.saveEmailAsInvoice
);
// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen
// POST /api/emails/:id/save-as-contract-document { documentType, notes?, deliveryDate? }
router.post(
'/emails/:id/save-as-contract-document',
authenticate,
requirePermission('contracts:update'),
cachedEmailController.saveEmailAsContractDocument
);
// Anhang als Rechnung speichern
// POST /api/emails/:id/attachments/:filename/save-as-invoice { invoiceDate, invoiceType, notes? }
router.post(
@@ -236,10 +245,14 @@ router.delete(
// E-Mails für ein Konto synchronisieren
// POST /api/stressfrei-emails/:id/sync?full=true
//
// KEIN `requirePermission('customers:update')` hier: Portal-Kunden
// dürfen ihr EIGENES Postfach synchronisieren sie haben aber nur
// `customers:read`. Der Mitarbeiter-Perm-Check und der Owner-Check
// laufen im Controller. (Pentest 2026-05-30 follow-up.)
router.post(
'/stressfrei-emails/:id/sync',
authenticate,
requirePermission('customers:update'),
cachedEmailController.syncAccount
);
+5 -1
View File
@@ -1,9 +1,13 @@
import { Router } from 'express';
import * as controller from '../controllers/consent-public.controller.js';
import { publicConsentRateLimiter } from '../middleware/rateLimit.js';
const router = Router();
// Öffentliche Routes - KEINE Authentifizierung erforderlich
// Öffentliche Routes - KEINE Authentifizierung erforderlich.
// Rate-Limit gegen DoS siehe publicConsentRateLimiter
// (Pentest 2026-05-20 INFO 28.4).
router.use(publicConsentRateLimiter);
router.get('/:hash', controller.getConsentPage);
router.post('/:hash/grant', controller.grantAllConsents);
router.get('/:hash/pdf', controller.getConsentPdf);
+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
+2
View File
@@ -37,6 +37,8 @@ router.get('/:customerId/portal', authenticate, requirePermission('customers:upd
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword);
router.post('/:customerId/portal/password/generate', authenticate, requirePermission('customers:update'), customerController.generatePortalPassword);
router.post('/:customerId/portal/send-credentials', authenticate, requirePermission('customers:update'), customerController.sendPortalCredentials);
// Representatives (Vertreter)
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
+5 -44
View File
@@ -1,54 +1,15 @@
import { Router, Response } from 'express';
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
const router = Router();
// Setup-Endpunkt: Erstellt die developer:access Permission und fügt sie der Admin-Rolle hinzu
// Dieser Endpunkt erfordert keine Authentifizierung, da er nur einmalig zum Setup verwendet wird
router.post('/setup', async (req, res: Response) => {
try {
// Create or get the developer:access permission
const developerPerm = await prisma.permission.upsert({
where: { resource_action: { resource: 'developer', action: 'access' } },
update: {},
create: { resource: 'developer', action: 'access' },
});
// Get the Admin role
const adminRole = await prisma.role.findUnique({
where: { name: 'Admin' },
include: { permissions: true },
});
if (!adminRole) {
res.status(404).json({ success: false, error: 'Admin-Rolle nicht gefunden' });
return;
}
// Check if Admin already has this permission
const hasPermission = adminRole.permissions.some(
(rp) => rp.permissionId === developerPerm.id
);
if (!hasPermission) {
await prisma.rolePermission.create({
data: {
roleId: adminRole.id,
permissionId: developerPerm.id,
},
});
res.json({ success: true, message: 'developer:access Permission wurde zur Admin-Rolle hinzugefügt. Bitte neu einloggen!' });
} else {
res.json({ success: true, message: 'Admin-Rolle hat bereits die developer:access Permission' });
}
} catch (error) {
console.error('Setup error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Setup' });
}
});
// HINWEIS: Der frühere `POST /setup`-Endpoint wurde entfernt (Pentest Runde 3
// 2026-05-16 KRITISCH). Er war ohne Auth erreichbar und konnte
// `developer:access` an die Admin-Rolle hängen → Privilege-Escalation auf
// volle DB-Kontrolle. Wenn die developer:access-Permission manuell gesetzt
// werden muss, gibt es das CLI-Script `prisma/add-developer-permission.ts`.
// Tabellen-Metadaten mit Beziehungen
const tableMetadata: Record<string, {
+6 -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
@@ -71,6 +72,9 @@ router.put('/website-privacy-policy', requirePermission('gdpr:admin'), gdprContr
// Consent-Link senden
router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink);
// Unterschreibbare Datenschutzerklärung als PDF (Papierform)
router.get('/customer/:customerId/privacy-pdf', requirePermission('customers:read'), gdprController.getSignablePrivacyPdf);
// Portal: Eigene Datenschutzseite (nur authenticate, Check im Controller)
router.get('/my-privacy', gdprController.getMyPrivacy);
router.get('/my-privacy/pdf', gdprController.getMyPrivacyPdf);
@@ -81,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;
+130 -13
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();
@@ -38,12 +64,18 @@ const fileFilter = (
file: Express.Multer.File,
cb: multer.FileFilterCallback
) => {
// Nur PDFs und Bilder erlauben
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
// PDFs + gängige Web-Bildformate. WebP + GIF nachgezogen 2026-05-30
// (Pentest INFO: WebP/GIF lieferten 500 statt sauberem 4xx, weil
// erlaubter MIME-Type fehlte und der fileFilter dann throwte).
const allowedTypes = [
'application/pdf',
'image/jpeg', 'image/jpg', 'image/png',
'image/gif', 'image/webp',
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Nur PDF, JPG und PNG Dateien sind erlaubt'));
cb(new Error('Nur PDF, JPG, PNG, GIF und WebP-Dateien sind erlaubt'));
}
};
@@ -51,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,
},
});
@@ -63,6 +98,10 @@ function setUploadDir(subDir: string) {
};
}
// 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(
'/bank-cards/:id',
@@ -70,6 +109,7 @@ router.post(
requirePermission('customers:update'),
setUploadDir('bank-cards'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
@@ -78,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 },
@@ -97,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' });
}
}
@@ -109,6 +161,7 @@ router.post(
requirePermission('customers:update'),
setUploadDir('documents'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
@@ -117,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 },
@@ -136,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' });
}
}
@@ -159,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' });
@@ -203,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' });
@@ -238,6 +307,7 @@ router.post(
requirePermission('customers:update'),
setUploadDir('business-registrations'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
@@ -246,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
@@ -286,6 +361,7 @@ router.post(
requirePermission('customers:update'),
setUploadDir('commercial-registers'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
@@ -294,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
@@ -335,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) {
@@ -375,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) {
@@ -416,6 +501,7 @@ router.post(
requirePermission('customers:update'),
setUploadDir('privacy-policies'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
@@ -424,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
@@ -484,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) {
@@ -572,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;
@@ -673,6 +776,7 @@ router.post(
requirePermission('contracts:update'),
setUploadDir('cancellation-letters'),
upload.single('document'),
validateUploadedFile,
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterPath', 'cancellation-letters')
);
@@ -690,6 +794,7 @@ router.post(
requirePermission('contracts:update'),
setUploadDir('cancellation-confirmations'),
upload.single('document'),
validateUploadedFile,
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationPath', 'cancellation-confirmations')
);
@@ -707,6 +812,7 @@ router.post(
requirePermission('contracts:update'),
setUploadDir('cancellation-letters-options'),
upload.single('document'),
validateUploadedFile,
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterOptionsPath', 'cancellation-letters-options')
);
@@ -724,6 +830,7 @@ router.post(
requirePermission('contracts:update'),
setUploadDir('cancellation-confirmations-options'),
upload.single('document'),
validateUploadedFile,
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options')
);
@@ -743,6 +850,7 @@ router.post(
requirePermission('contracts:update'),
setUploadDir('invoices'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
@@ -751,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);
@@ -803,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' });
+5
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import * as userController from '../controllers/user.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { staffPasswordReAuthLimiter } from '../middleware/rateLimit.js';
const router = Router();
@@ -10,6 +11,10 @@ router.post('/', authenticate, requirePermission('users:create'), userController
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser);
// Passwort-Reset durch Admin dedizierter Endpoint (Pentest Runde 12).
// 47.3 verlangt Re-Auth (currentPassword), 48.3 wirft einen Rate-Limit
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
// Roles
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
+117
View File
@@ -1,4 +1,6 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
import { validateHttpUrl } from '../utils/url.js';
// Default settings
const DEFAULT_SETTINGS: Record<string, string> = {
@@ -12,6 +14,121 @@ const DEFAULT_SETTINGS: Record<string, string> = {
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
};
// Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE
// anderen Keys mehr an (Pentest Runde 11 (2026-05-18) M1: Mass
// Assignment, "superAdminEmail", "debugMode", "allowedOrigins" landeten
// vorher ungefiltert in der DB).
export const ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
...Object.keys(DEFAULT_SETTINGS),
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
'monitoringAlertEmail',
'monitoringDigestEnabled',
'monitoringLastDigestAt',
'companyName',
'defaultEmailDomain',
// Basis-URL für an Kunden verschickte Portal-Links (Login + Passwort-Reset).
// Vorher kam aus `PUBLIC_URL`-Env, default localhost Mails enthielten
// dann unklickbare Links. Wird in Settings → Kundenportal gepflegt.
'portalLoginUrl',
]);
export function isAllowedSettingKey(key: string): boolean {
return ALLOWED_SETTING_KEYS.has(key);
}
// Keys deren Wert legitim HTML enthalten darf (Datenschutz-/Impressum-Editoren
// liefern WYSIWYG-HTML). Alle anderen Plain-Text-Keys (companyName,
// defaultEmailDomain, Schwellenwerte etc.) werden vor dem Persistieren durch
// stripHtml geschickt Pentest 2026-05-19, MEDIUM: <img src=x onerror=alert(1)>
// in companyName landete ungefiltert in der DB und konnte später z.B. in
// E-Mail-Templates oder PDF-Generatoren unescaped landen.
const HTML_ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
]);
/**
* Bereinigt den Wert vor dem Speichern: für Plain-Text-Keys werden alle
* HTML-Tags entfernt. Die dedizierten Editor-Keys
* (imprintHtml/privacyPolicyHtml/...) bleiben unverändert, da sie sonst
* den WYSIWYG-Editor unbenutzbar machen würden sie werden über
* dedizierte /api/gdpr-Endpoints gepflegt.
*/
export function sanitizeSettingValue(key: string, value: string): string {
if (HTML_ALLOWED_SETTING_KEYS.has(key)) return value;
const stripped = stripHtml(value);
return typeof stripped === 'string' ? stripped : String(stripped);
}
/**
* Schema-spezifische Wert-Validierung VOR dem Speichern. Wird vom
* Controller aufgerufen; liefert entweder { ok: true, value: <sanitized> }
* oder { ok: false, error: <message> } für 400.
*
* Hintergrund Pentest 2026-05-28 LOW 34.5: Schema-Whitelist und
* Slash-Trimming standen NUR im Frontend, der API-Endpoint nahm
* relative URLs (`/evil/path`), `javascript:`-Schemata und Adressen
* auf private Hosts (`http://192.168.1.1`) ungeprüft entgegen. Bei
* Cloud-Deployment war das ein SSRF-/Open-Redirect-Vektor in der
* an Kunden verschickten Mail.
*/
export function validateSettingValue(key: string, rawValue: string): { ok: true; value: string } | { ok: false; error: string } {
// Schwellenwerte: müssen positive ganze Zahlen sein, sonst läuft das
// Cockpit in NaN-Vergleichen. Bestehende Validierung war nicht
// konsequent.
const intKeys = new Set(['deadlineCriticalDays', 'deadlineWarningDays', 'deadlineOkDays', 'documentExpiryCriticalDays', 'documentExpiryWarningDays']);
if (intKeys.has(key)) {
const trimmed = rawValue.trim();
if (!/^\d+$/.test(trimmed)) {
return { ok: false, error: `${key} muss eine positive ganze Zahl sein.` };
}
return { ok: true, value: trimmed };
}
// Bool-Settings
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') {
const trimmed = rawValue.trim().toLowerCase();
if (trimmed !== 'true' && trimmed !== 'false') {
return { ok: false, error: `${key} muss 'true' oder 'false' sein.` };
}
return { ok: true, value: trimmed };
}
// Email-Settings (Format-Check analog zu Customer/User verhindert
// Header-Injection in System-Mails)
if (key === 'monitoringAlertEmail') {
const trimmed = rawValue.trim();
if (trimmed === '') return { ok: true, value: '' };
// RFC-5322-light, gleiches Pattern wie isValidEmail in utils/sanitize
if (/[\r\n\t\0\v\f]/.test(trimmed) || trimmed.length > 254) {
return { ok: false, error: 'Ungültige E-Mail-Adresse.' };
}
if (!/^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(trimmed)) {
return { ok: false, error: 'Ungültiges E-Mail-Format.' };
}
return { ok: true, value: trimmed };
}
// Portal-Login-URL: nur http/https, keine privaten/loopback-Hosts.
// Strikter als `isBlockedSsrfHost`, weil der Wert in Mails an Endkunden
// landet die können 127.0.0.1/10.x/172.16.x/192.168.x ohnehin nicht
// erreichen. (Pentest Runde 35 LOW 34.5-followup, on-prem-Override
// SSRF_BLOCK_PRIVATE_IPS gilt hier explizit NICHT.)
// Werte mit Pfad/Query sind erlaubt Mail-Versand hängt ohnehin
// /portal/login hinten dran, eine evtl. Pfad-Komponente bleibt.
if (key === 'portalLoginUrl') {
return validateHttpUrl(rawValue, { fieldLabel: 'Portal-Login-URL' });
}
// Default: kein zusätzlicher Format-Check
return { ok: true, value: rawValue };
}
export async function getSetting(key: string): Promise<string | null> {
const setting = await prisma.appSetting.findUnique({
where: { key },
+217 -16
View File
@@ -6,6 +6,8 @@ import { JwtPayload } from '../types/index.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import { getAuthorizedCustomerIds } from './authorization.service.js';
import * as appSettingService from './appSetting.service.js';
// Token-Lifetimes
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
@@ -27,6 +29,17 @@ export function signRefreshToken(payload: JwtPayload): string {
});
}
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
// abgerufen, wenn ein Endpoint per `?token=` aufgerufen werden muss
// (z.B. PDF-iframe, Audit-Export-Window). Selbst wenn dieser Token in
// nginx-Access-Logs oder der Browser-History landet, ist er nach
// 60 Sekunden wertlos. Pentest Runde 7 (2026-05-17) NIEDRIG.
export function signDownloadToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'download' }, process.env.JWT_SECRET as string, {
expiresIn: '60s',
});
}
// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
const BCRYPT_COST = 12;
@@ -180,19 +193,42 @@ export async function customerLogin(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten');
}
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Einmalpasswort-Check: wurde es per "Zugangsdaten versenden" verschickt?
// Falls ja, jetzt sofort verbrauchen Hash + Encrypted nullen, damit
// weder Re-Login noch Klartext-Abruf möglich ist. Customer landet im
// Force-Change-Password-Flow.
const mustChangePassword = customer.portalPasswordMustChange === true;
if (mustChangePassword) {
await prisma.customer.update({
where: { id: customer.id },
data: {
portalPasswordHash: null,
portalPasswordEncrypted: null,
portalLastLogin: new Date(),
},
});
} else {
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
data: { portalLastLogin: new Date() },
});
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
data: { portalLastLogin: new Date() },
});
}
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
const representedCustomerIds = customer.representingFor.map(
(rep) => rep.customer.id
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
// GEFILTERT auf aktive Vollmacht (isGranted: true). Ohne diesen Filter
// hätte das frische JWT nach Vollmacht-Widerruf weiterhin die alte
// representedCustomerIds-Liste; die UI würde dem Vertreter noch
// anzeigen, dass er vertreten kann, obwohl der Live-Check beim
// Datenzugriff dann 403 wirft. Pentest Runde 10 (2026-05-17), MEDIUM.
const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
const grantedRepresentingFor = customer.representingFor.filter((rep) =>
grantedCustomerIds.has(rep.customer.id),
);
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
// Kundenportal-Berechtigungen (eingeschränkt)
const customerPermissions = [
@@ -214,6 +250,7 @@ export async function customerLogin(email: string, password: string) {
return {
accessToken,
refreshToken,
mustChangePassword,
user: {
id: customer.id,
email: customer.portalEmail,
@@ -222,7 +259,8 @@ export async function customerLogin(email: string, password: string) {
permissions: customerPermissions,
customerId: customer.id,
isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({
mustChangePassword,
representedCustomers: grantedRepresentingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName,
@@ -331,17 +369,45 @@ export async function setCustomerPortalPassword(customerId: number, password: st
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
// Manuelles Setzen ist KEIN Einmalpasswort → Flag immer zurücksetzen,
// falls vorher ein OTP gesetzt war.
await prisma.customer.update({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: encryptedPassword,
portalPasswordMustChange: false,
},
});
console.log('[SetPortalPassword] Passwort gespeichert');
}
// Vom Endkunden selbst gesetztes Initial-Passwort nach OTP-Login.
// Speichert neuen Hash, löscht das verbrauchte Encrypted-Feld (Klartext-
// Speicherung soll bei OFF self-service nicht zurückkommen) und invalidiert
// sofort alle bestehenden Sessions, damit Login mit dem neuen Passwort
// gefordert wird.
export async function changeInitialPortalPassword(customerId: number, newPassword: string) {
const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.customer.update({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: null,
portalPasswordMustChange: false,
portalTokenInvalidatedAt: new Date(),
},
});
}
export async function markPortalPasswordForChange(customerId: number) {
await prisma.customer.update({
where: { id: customerId },
data: { portalPasswordMustChange: true },
});
}
// Kundenportal-Passwort im Klartext abrufen
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
const customer = await prisma.customer.findUnique({
@@ -481,6 +547,13 @@ export async function getCustomerPortalUser(customerId: number) {
'customers:read',
];
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
const grantedRepresentingFor = customer.representingFor.filter((rep) =>
grantedCustomerIds.has(rep.customer.id),
);
return {
id: customer.id,
email: customer.portalEmail,
@@ -490,7 +563,7 @@ export async function getCustomerPortalUser(customerId: number) {
customerId: customer.id,
permissions: customerPermissions,
isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({
representedCustomers: grantedRepresentingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName,
@@ -509,8 +582,109 @@ function generateResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
function getPublicUrl(): string {
return process.env.PUBLIC_URL || 'http://localhost:5173';
/**
* Liefert die Basis-URL für an Kunden verschickte Links (Portal-Login,
* Passwort-Reset). Reihenfolge:
* 1. `portalLoginUrl` aus AppSettings (vom Admin in Settings → Kundenportal
* gepflegt). Wenn HTTPS-Domain hier eingetragen, wird die in Mails
* verwendet, nicht der Localhost-Default.
* 2. `PUBLIC_URL`-Env (für Setups ohne Admin-UI-Konfiguration).
* 3. Fallback `http://localhost:5173` (Dev-Default).
* Hat Trailing-Slash-Bereinigung, sonst kommen Links wie
* `https://crm.de//portal/login` zustande.
*/
// 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
|| 'http://localhost:5173';
return raw.replace(/\/+$/, '');
}
/**
* Portal-Zugangsdaten per E-Mail an den Kunden versenden. Nur durch Admin-
* UI ausgelöst nie automatisch , weil das Klartext-Passwort im Mail-
* Body steht. Login-URL zeigt auf das `/portal/login`-Frontend-Route.
*/
export async function sendPortalCredentialsEmail(params: {
to: string;
customer: { firstName: string | null; lastName: string | null; salutation: string | null; companyName: string | null };
loginEmail: string;
password: string;
}): Promise<void> {
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
throw new Error('Kein System-E-Mail-Konto konfiguriert (Einstellungen → E-Mail-Provider)');
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const loginUrl = `${await getPublicUrl()}/portal/login`;
const name = params.customer.companyName?.trim()
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|| 'Kunde';
// HTML-Escape Customer-Namen können theoretisch Sonderzeichen enthalten,
// die wir nicht ungefiltert in die Mail rendern wollen.
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Ihre Zugangsdaten zum Kundenportal</h2>
<p>Hallo ${esc(name)},</p>
<p>anbei Ihre Zugangsdaten zum Kundenportal:</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr><td style="padding: 6px 12px; color: #6b7280;">Login-URL:</td>
<td style="padding: 6px 12px;"><a href="${loginUrl}">${esc(loginUrl)}</a></td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280;">E-Mail:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.loginEmail)}</td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280;">Passwort:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.password)}</td></tr>
</table>
<p style="color: #b91c1c; font-size: 14px; font-weight: 600;">
⚠️ Dieses Passwort ist ein <u>Einmalpasswort</u>.
</p>
<p style="color: #6b7280; font-size: 14px;">
Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben.
Danach ist dieses Passwort hier <strong>nicht mehr gültig</strong> falls Sie den
Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die
Passwort-vergessen-Funktion.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Diese Nachricht enthält sensible Zugangsdaten bitte sicher verwahren oder nach
dem Login löschen.
</p>
</div>
`;
await sendEmail(
credentials,
systemEmail.emailAddress,
{
to: params.to,
subject: 'Ihre Zugangsdaten zum Kundenportal',
html,
},
{
context: 'portal-credentials',
triggeredBy: 'admin-action',
},
);
}
/**
@@ -557,7 +731,7 @@ export async function requestPasswordReset(email: string, userType: 'admin' | 'p
if (!recipient) return;
// Reset-Link + Email senden
const resetUrl = `${getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
const resetUrl = `${await getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
@@ -616,6 +790,26 @@ export async function requestPasswordReset(email: string, userType: 'admin' | 'p
);
}
/**
* Stellt fest, ob ein Reset-Token zu einem Mitarbeiter (admin) oder einem
* Portal-Customer (portal) gehört. Wird vom Controller benötigt, um den
* passenden Komplexitäts-Schwellwert (25 bzw. 12 Zeichen) anzuwenden,
* BEVOR das Passwort tatsächlich gesetzt wird. Pentest Runde 13.
*/
export async function getPasswordResetAudience(token: string): Promise<'admin' | 'portal' | null> {
const user = await prisma.user.findUnique({
where: { passwordResetToken: token },
select: { id: true },
});
if (user) return 'admin';
const customer = await prisma.customer.findUnique({
where: { portalPasswordResetToken: token },
select: { id: true },
});
if (customer) return 'portal';
return null;
}
/**
* Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen.
* Invalidiert alle bestehenden JWT-Sessions des Users.
@@ -656,11 +850,18 @@ export async function confirmPasswordReset(token: string, newPassword: string):
where: { id: customer.id },
data: {
portalPasswordHash: hash,
portalPasswordEncrypted: encrypt(newPassword),
// Pentest Runde 6 (MITTEL-01): Beim Self-Service-Reset speichern wir
// KEINEN Klartext mehr. Encrypted-Feld ist nur für Admin-generierte
// Einmalpasswörter sinnvoll (damit Admin sie in der UI sehen + per
// Mail versenden kann); für ein vom Kunden selbst gesetztes Passwort
// ist Klartext-Speicherung ein unnötiges Recover-Risiko bei DB+Key-Leak.
portalPasswordEncrypted: null,
portalPasswordResetToken: null,
portalPasswordResetExpiresAt: null,
// Alle bestehenden Portal-Sessions kicken
portalTokenInvalidatedAt: new Date(),
// OTP-Flow-Flag ist nach selbstgesetztem Passwort definitiv aus
portalPasswordMustChange: false,
},
});
return;
@@ -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 },
+58 -9
View File
@@ -138,6 +138,24 @@ function deleteDirectory(dirPath: string): void {
fs.rmdirSync(dirPath);
}
// Wie deleteDirectory, ABER das Ziel-Verzeichnis selbst bleibt stehen
// nur die Inhalte verschwinden. Notwendig für Docker-Bind-Mounts wie
// `/app/uploads`: dort wirft `rmdir` ein EBUSY, weil das Volume vom Host
// gemountet ist und sich nicht aushängen lässt.
function emptyDirectory(dirPath: string): void {
if (!fs.existsSync(dirPath)) return;
const items = fs.readdirSync(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = fs.lstatSync(itemPath);
if (stats.isDirectory()) {
deleteDirectory(itemPath);
} else {
fs.unlinkSync(itemPath);
}
}
}
/**
* Liste aller verfügbaren Backups
*/
@@ -926,10 +944,10 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
let restoredFiles = 0;
const uploadsBackupDir = path.join(backupDir, 'uploads');
if (fs.existsSync(uploadsBackupDir)) {
// Bestehenden Uploads-Ordner leeren (optional: könnte auch nur überschreiben)
if (fs.existsSync(UPLOADS_DIR)) {
deleteDirectory(UPLOADS_DIR);
}
// Inhalte leeren, das Verzeichnis selbst NICHT löschen
// UPLOADS_DIR ist im Container ein Bind-Mount auf den Host und
// `rmdir` darauf liefert EBUSY (siehe emptyDirectory()).
emptyDirectory(UPLOADS_DIR);
restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR);
}
@@ -1022,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`,
@@ -1045,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
+58 -38
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[]> {
// 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 },
};
if (options?.stressfreiEmailId) {
where.stressfreiEmailId = options.stressfreiEmailId;
}
if (options?.contractId) {
where.contractId = options.contractId;
}
return prisma.cachedEmail.findMany({
where: {
isDeleted: true,
stressfreiEmail: {
customerId,
},
},
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: {
isDeleted: true,
stressfreiEmail: {
customerId,
},
},
});
// 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 },
};
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)
+141 -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;
@@ -185,3 +215,105 @@ export async function generateConsentPdf(customerId: number): Promise<Buffer> {
doc.end();
});
}
/**
* Datenschutzerklärung als unterschreibbare PDF (Papierform) generieren.
* Zusätzlich zum normalen Text wird unten eine Einwilligungs-Klausel +
* ein Unterschriften-Block angefügt (Ort/Datum + Unterschrift +
* Name in Druckbuchstaben). Das fertige PDF wird ausgedruckt, vom
* Kunden unterschrieben und im Tab "Einwilligungen / Datenschutz"
* wieder hochgeladen.
*/
export async function generateSignablePrivacyPdf(customerId: number): Promise<Buffer> {
const html = await getPrivacyPolicyHtml(customerId);
const text = htmlToText(html);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true, lastName: true, customerNumber: true, companyName: true,
salutation: true,
},
});
const printedName = customer
? (customer.companyName?.trim()
? customer.companyName.trim()
: `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim())
: '';
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Titel
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
doc.moveDown(0.5);
// Kundenkopf
if (printedName) {
doc.fontSize(11).font('Helvetica-Bold').text(printedName, { align: 'center' });
}
if (customer?.customerNumber) {
doc.fontSize(10).font('Helvetica').text(`Kundennummer: ${customer.customerNumber}`, { align: 'center' });
}
doc.moveDown(0.5);
doc.fontSize(10).font('Helvetica')
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
doc.moveDown(1);
// Inhalt
doc.fontSize(11).font('Helvetica').text(text, { align: 'left', lineGap: 4 });
// Genug Platz vor dem Unterschriftenblock wenn nicht mehr genug
// Platz auf der Seite, neue Seite anfangen.
if (doc.y > doc.page.height - doc.page.margins.bottom - 220) {
doc.addPage();
} else {
doc.moveDown(2);
}
// Einwilligungsklausel
doc.fontSize(11).font('Helvetica-Bold').text('Einwilligung', { underline: false });
doc.moveDown(0.3);
doc.fontSize(10).font('Helvetica').text(
'Mit meiner Unterschrift bestätige ich, dass ich die vorstehende ' +
'Datenschutzerklärung gelesen und verstanden habe und mit der ' +
'Verarbeitung meiner personenbezogenen Daten zum Zweck der ' +
'Vertragserfüllung einverstanden bin. Diese Einwilligung kann ' +
'jederzeit für die Zukunft widerrufen werden.',
{ align: 'left', lineGap: 3 },
);
doc.moveDown(1.5);
// Unterschriftenblock: links Ort/Datum, rechts Unterschrift
const startY = doc.y;
const leftX = doc.page.margins.left;
const rightX = doc.page.width / 2 + 10;
const lineWidth = doc.page.width / 2 - doc.page.margins.left - 10;
// Linien
const lineY = startY + 35;
doc.moveTo(leftX, lineY).lineTo(leftX + lineWidth, lineY).stroke();
doc.moveTo(rightX, lineY).lineTo(rightX + lineWidth, lineY).stroke();
// Labels unter den Linien
doc.fontSize(9).font('Helvetica');
doc.text('Ort, Datum', leftX, lineY + 4, { width: lineWidth, align: 'left' });
doc.text('Unterschrift', rightX, lineY + 4, { width: lineWidth, align: 'left' });
// Zweite Zeile: Name in Druckbuchstaben (vorausgefüllt mit Kunde)
doc.moveDown(3);
const nameY = doc.y;
doc.fontSize(11).font('Helvetica');
if (printedName) {
doc.text(printedName, rightX, nameY, { width: lineWidth, align: 'left' });
}
doc.moveTo(rightX, nameY + 16).lineTo(rightX + lineWidth, nameY + 16).stroke();
doc.fontSize(9).text('Name in Druckbuchstaben', rightX, nameY + 20, { width: lineWidth, align: 'left' });
doc.end();
});
}
+25
View File
@@ -2,6 +2,25 @@ 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
// 'public-link', CRM-Backend-Override 'crm-backend'. Alles andere
// (z.B. "ADMIN_OVERRIDE", "<script>") wird abgelehnt Pentest 2026-05-20.
export const ALLOWED_CONSENT_SOURCES: ReadonlySet<string> = new Set([
'portal',
'public-link',
'telefon',
'papier',
'email',
'crm-backend',
]);
export function sanitizeConsentSource(value: unknown, fallback: string): string {
const v = typeof value === 'string' ? value : '';
return ALLOWED_CONSENT_SOURCES.has(v) ? v : fallback;
}
export interface UpdateConsentData {
status: ConsentStatus;
@@ -62,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,
+59 -7
View File
@@ -2,6 +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, sanitizePhoneField } from '../utils/sanitize.js';
export interface ContractFilters {
customerId?: number;
@@ -154,7 +155,18 @@ export async function getContractById(id: number, decryptPassword = false) {
if (!contract) return null;
// Decrypt password if requested and exists
// SECURITY: Embedded Customer-Objekt sanitizen, sonst leaken
// portalPasswordHash + portalPasswordEncrypted + Reset-Token in jede
// contract.customer-Response. Der direkte `/customers/:id`-Endpoint hat
// den Schutz schon; hier wäre er ohne Sanitize bypassbar.
if (contract.customer) {
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
contract.customer as Record<string, unknown>,
);
}
// Decrypt password if requested and exists (Contract-Anbieter-Passwort,
// nicht zu verwechseln mit Customer-Portal-Passwort)
if (decryptPassword && contract.portalPasswordEncrypted) {
try {
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
@@ -165,6 +177,13 @@ export async function getContractById(id: number, decryptPassword = false) {
}
}
// Virtuelles Bool-Flag, damit das Frontend "PW gesetzt?" weiß, ohne dass
// der verschlüsselte Blob in die Response leakt (sanitizeContract strippt
// portalPasswordEncrypted bewusst). Pentest Runde 15 sensitive Feld
// raus aus /contracts/:id; UI nutzt jetzt `hasPortalPassword`.
(contract as Record<string, unknown>).hasPortalPassword =
!!contract.portalPasswordEncrypted;
return contract;
}
@@ -184,6 +203,9 @@ interface ContractCreateData {
providerName?: string;
tariffName?: string;
customerNumberAtProvider?: string;
orderNumberAtSalesPlatform?: string;
customerNumberAtSalesPlatform?: string;
contractNumberAtSalesPlatform?: string;
priceFirst12Months?: string;
priceFrom13Months?: string;
priceAfter24Months?: string;
@@ -206,7 +228,8 @@ interface ContractCreateData {
annualConsumption?: number;
basePrice?: number;
unitPrice?: number;
bonus?: number;
instantBonus?: number;
newCustomerBonus?: number;
previousProviderName?: string;
previousCustomerNumber?: string;
};
@@ -230,6 +253,7 @@ interface ContractCreateData {
phoneNumbers?: {
id?: number;
phoneNumber: string;
areaCode?: string;
isMain?: boolean;
sipUsername?: string;
sipPassword?: string;
@@ -255,6 +279,8 @@ interface ContractCreateData {
puk?: string;
isMultisim?: boolean;
isMain?: boolean;
isEsim?: boolean;
cardUser?: string;
}[];
};
tvDetails?: {
@@ -323,7 +349,8 @@ export async function createContract(data: ContractCreateData) {
phoneNumbers: internetDetails.phoneNumbers && internetDetails.phoneNumbers.length > 0
? {
create: internetDetails.phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPassword
@@ -358,6 +385,8 @@ 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,
})),
}
: undefined,
@@ -385,6 +414,15 @@ export async function createContract(data: ContractCreateData) {
},
});
// Embedded Customer-Objekt sanitizen (siehe getContractById derselbe
// Schutz; createContract gibt den frisch erstellten Vertrag inkl. Customer
// zurück, und der darf keine Passwort-Hashes/-Encryptions leaken).
if (contract.customer) {
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
contract.customer as Record<string, unknown>,
);
}
return contract;
}
@@ -510,7 +548,8 @@ export async function updateContract(
return {
internetContractDetailsId: existing.id,
phoneNumber: pn.phoneNumber,
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
// Preserve existing sipPassword if no new value provided
@@ -533,7 +572,8 @@ export async function updateContract(
phoneNumbers: phoneNumbers
? {
create: phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPassword
@@ -585,6 +625,8 @@ 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,
};
}),
});
@@ -603,6 +645,8 @@ 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,
})),
}
: undefined,
@@ -689,7 +733,8 @@ export async function createFollowUpContract(previousContractId: number) {
previousContract.energyDetails.annualConsumption ?? undefined,
basePrice: previousContract.energyDetails.basePrice ?? undefined,
unitPrice: previousContract.energyDetails.unitPrice ?? undefined,
bonus: previousContract.energyDetails.bonus ?? undefined,
instantBonus: previousContract.energyDetails.instantBonus ?? undefined,
newCustomerBonus: previousContract.energyDetails.newCustomerBonus ?? undefined,
previousProviderName: previousContract.providerName ?? undefined,
previousCustomerNumber:
previousContract.customerNumberAtProvider ?? undefined,
@@ -728,6 +773,7 @@ export async function createFollowUpContract(previousContractId: number) {
simCardNumber: sc.simCardNumber ?? undefined,
isMultisim: sc.isMultisim,
isMain: sc.isMain,
isEsim: sc.isEsim,
})),
};
}
@@ -853,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,
@@ -877,7 +927,8 @@ export async function createRenewalContract(previousContractId: number) {
basePrice: ed.basePrice,
unitPrice: ed.unitPrice,
unitPriceNt: ed.unitPriceNt,
bonus: ed.bonus,
instantBonus: ed.instantBonus,
newCustomerBonus: ed.newCustomerBonus,
previousProviderName: ed.previousProviderName,
previousCustomerNumber: ed.previousCustomerNumber,
},
@@ -951,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,
},
+29 -11
View File
@@ -183,7 +183,7 @@ function calculateCancellationDeadline(
return end;
}
export async function getCockpitData(): Promise<CockpitResult> {
export async function getCockpitData(opts?: { customerIds?: number[] }): Promise<CockpitResult> {
// Lade Einstellungen
const settings = await appSettingService.getAllSettings();
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
@@ -192,12 +192,19 @@ export async function getCockpitData(): Promise<CockpitResult> {
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90;
// Portal-Filter: Wenn customerIds gesetzt sind (Kundenportal-User), beschränken
// wir ALLE Cockpit-Queries auf diese Customer-IDs. Leeres Array → keine Treffer.
const customerScopeFilter = opts?.customerIds
? { customerId: { in: opts.customerIds } }
: {};
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
const contracts = await prisma.contract.findMany({
where: {
status: {
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
},
...customerScopeFilter,
},
include: {
customer: {
@@ -283,9 +290,9 @@ export async function getCockpitData(): Promise<CockpitResult> {
},
};
// Consent-Daten batch-laden für alle Kunden
// Consent-Daten batch-laden für alle (erlaubten) Kunden
const allConsents = await prisma.customerConsent.findMany({
where: { status: 'GRANTED' },
where: { status: 'GRANTED', ...customerScopeFilter },
select: { customerId: true, consentType: true },
});
@@ -300,7 +307,7 @@ export async function getCockpitData(): Promise<CockpitResult> {
// Widerrufene Consents laden
const withdrawnConsents = await prisma.customerConsent.findMany({
where: { status: 'WITHDRAWN' },
where: { status: 'WITHDRAWN', ...customerScopeFilter },
select: { customerId: true, consentType: true },
});
const withdrawnConsentsMap = new Map<number, Set<string>>();
@@ -487,8 +494,11 @@ export async function getCockpitData(): Promise<CockpitResult> {
summary.byCategory.missingData++;
}
// 7b. KEIN AUSWEIS (nur für Telekommunikationsprodukte relevant)
const requiresIdentityDocument = ['DSL', 'FIBER', 'CABLE', 'MOBILE'].includes(contract.type);
// 7b. KEIN AUSWEIS nur Mobilfunk. Bei Festnetz/Internet (DSL, FIBER,
// CABLE) verlangt der Anbieter beim Auftrag keinen Ausweis, die
// Warnung ist da nur Rauschen. Mobile bleibt drin, weil dort echte
// Identitätsfeststellung Pflicht ist.
const requiresIdentityDocument = contract.type === 'MOBILE';
if (requiresIdentityDocument && !contract.identityDocumentId) {
issues.push({
type: 'missing_identity_document',
@@ -733,10 +743,10 @@ export async function getCockpitData(): Promise<CockpitResult> {
});
// Vertragsunabhängige Ausweis-Warnungen
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays);
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays, opts?.customerIds);
// Gemeldete Zählerstände (REPORTED Status)
const reportedReadings = await getReportedMeterReadings();
const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
return {
contracts: cockpitContracts,
@@ -754,7 +764,11 @@ export async function getCockpitData(): Promise<CockpitResult> {
/**
* Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig)
*/
async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number): Promise<DocumentAlert[]> {
async function getDocumentExpiryAlerts(
criticalDays: number,
warningDays: number,
customerIds?: number[],
): Promise<DocumentAlert[]> {
const now = new Date();
const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
@@ -762,6 +776,7 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number
where: {
isActive: true,
expiryDate: { lte: inWarningDays },
...(customerIds ? { customerId: { in: customerIds } } : {}),
},
include: {
customer: {
@@ -798,9 +813,12 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number
/**
* Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden
*/
async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> {
async function getReportedMeterReadings(customerIds?: number[]): Promise<ReportedMeterReading[]> {
const readings = await prisma.meterReading.findMany({
where: { status: 'REPORTED' },
where: {
status: 'REPORTED',
...(customerIds ? { meter: { customerId: { in: customerIds } } } : {}),
},
include: {
meter: {
include: {
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
export interface CreateHistoryEntryData {
title: string;
@@ -7,23 +8,36 @@ export interface CreateHistoryEntryData {
createdBy: string;
}
// Read-Time-Defensive: title + description durch stripHtml schicken, damit
// Alt-Einträge (vor Pentest 43.6) mit rohen HTML-Payloads nicht roh
// rausgehen. Schützt zusätzlich gegen einen umgangenen Write-Filter.
function sanitizeEntry<T extends { title: string; description: string | null }>(entry: T): T {
return {
...entry,
title: stripHtml(entry.title) as string,
description: entry.description != null ? (stripHtml(entry.description) as string) : entry.description,
};
}
/**
* Alle Historie-Einträge für einen Vertrag abrufen
*/
export async function getHistoryEntries(contractId: number) {
return prisma.contractHistoryEntry.findMany({
const entries = await prisma.contractHistoryEntry.findMany({
where: { contractId },
orderBy: { createdAt: 'desc' },
});
return entries.map(sanitizeEntry);
}
/**
* Einzelnen Historie-Eintrag abrufen
*/
export async function getHistoryEntry(contractId: number, entryId: number) {
return prisma.contractHistoryEntry.findFirst({
const entry = await prisma.contractHistoryEntry.findFirst({
where: { id: entryId, contractId },
});
return entry ? sanitizeEntry(entry) : null;
}
/**
@@ -39,11 +53,14 @@ export async function createHistoryEntry(contractId: number, data: CreateHistory
throw new Error('Vertrag nicht gefunden');
}
// Pentest 2026-05-30 (MEDIUM, 43.6): Admin konnte HTML-Tags in title +
// description schreiben, Portal-User las sie roh zurück. stripHtml räumt
// Tags + gefährliche URI-Schemata vor dem Persistieren weg.
return prisma.contractHistoryEntry.create({
data: {
contractId,
title: data.title,
description: data.description,
title: stripHtml(data.title) as string,
description: data.description != null ? (stripHtml(data.description) as string) : data.description,
isAutomatic: data.isAutomatic ?? false,
createdBy: data.createdBy,
},
@@ -73,8 +90,8 @@ export async function updateHistoryEntry(
return prisma.contractHistoryEntry.update({
where: { id: entryId },
data: {
title: data.title,
description: data.description,
title: data.title != null ? (stripHtml(data.title) as string) : undefined,
description: data.description != null ? (stripHtml(data.description) as string) : undefined,
},
});
}
@@ -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:
+241 -3
View File
@@ -22,10 +22,13 @@ export interface CustomerFilters {
type?: CustomerType;
page?: number;
limit?: number;
// Wenn gesetzt: nur Customer mit id in dieser Liste. Für Portal-User, damit
// weder Liste noch pagination.total die globale Kunden-Zahl preisgibt.
allowedIds?: number[];
}
export async function getAllCustomers(filters: CustomerFilters) {
const { search, type, page = 1, limit = 20 } = filters;
const { search, type, page = 1, limit = 20, allowedIds } = filters;
const { skip, take } = paginate(page, limit);
const where: Record<string, unknown> = {};
@@ -34,6 +37,10 @@ export async function getAllCustomers(filters: CustomerFilters) {
where.type = type;
}
if (allowedIds) {
where.id = { in: allowedIds };
}
if (search) {
where.OR = [
{ firstName: { contains: search } },
@@ -76,9 +83,28 @@ export async function getCustomerById(id: number) {
meters: {
orderBy: { isActive: 'desc' },
include: {
address: true,
readings: {
orderBy: { readingDate: 'desc' },
},
// Verträge, die diesen Zähler aktuell als Hauptzähler nutzen
// (energyDetails.meterId === meter.id)
energyDetails: {
include: {
contract: { select: { id: true, contractNumber: true, status: true, type: true, providerName: true } },
},
},
// Verträge, in denen der Zähler in der ContractMeter-Kette steht
// (Vorgänger oder Nachfolger über Zählerwechsel)
contractMeters: {
include: {
energyContractDetails: {
include: {
contract: { select: { id: true, contractNumber: true, status: true, type: true, providerName: true } },
},
},
},
},
},
},
stressfreiEmails: { orderBy: { isActive: 'desc' } },
@@ -403,6 +429,7 @@ export async function getCustomerMeters(
return prisma.meter.findMany({
where,
include: {
address: true,
readings: {
orderBy: { readingDate: 'desc' },
take: 5,
@@ -412,21 +439,224 @@ export async function getCustomerMeters(
});
}
// Schreibt den Endstand des Vorgänger-Zählers beim Zählerwechsel als
// MeterReading. Wird beim Folgezähler-Anlegen aufgerufen (sowohl aus der
// Kundenakte als auch aus der Vertragsansicht). Idempotent: existiert am
// Wechseltag schon ein Reading, wird nichts angelegt. Validierung
// monoton-steigend wird durchgereicht wirft bei Konflikt.
export async function recordPredecessorFinalReading(
predecessorMeterId: number,
switchAt: Date,
value: number,
) {
const meter = await prisma.meter.findUnique({
where: { id: predecessorMeterId },
select: { type: true },
});
if (!meter) return;
const dayStart = new Date(switchAt);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayEnd.getDate() + 1);
const existingSameDay = await prisma.meterReading.findFirst({
where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } },
});
if (existingSameDay) return;
await validateReadingValue(predecessorMeterId, switchAt, value, undefined, 'HT');
await prisma.meterReading.create({
data: {
meterId: predecessorMeterId,
readingDate: switchAt,
value,
unit: meter.type === 'GAS' ? 'm³' : 'kWh',
notes: 'Endstand bei Zählerwechsel (automatisch beim Folgezähler-Anlegen erfasst)',
},
});
}
// Lieferadresse muss zum Kunden gehören und vom Typ DELIVERY_RESIDENCE sein.
// Wirft eine sprechende Fehlermeldung, die der Controller dem User durchreicht.
async function assertDeliveryAddressBelongsToCustomer(addressId: number, customerId: number) {
const addr = await prisma.address.findUnique({ where: { id: addressId } });
if (!addr || addr.customerId !== customerId) {
throw new Error('Ungültige Lieferadresse');
}
if (addr.type !== 'DELIVERY_RESIDENCE') {
throw new Error('Nur Lieferadressen können einem Zähler zugeordnet werden');
}
}
export async function createMeter(
customerId: number,
data: {
meterNumber: string;
type: 'ELECTRICITY' | 'GAS';
tariffModel?: 'SINGLE' | 'DUAL';
location?: string;
addressId?: number | null;
// Optional: dieser Zähler ersetzt einen bestehenden (Folgezähler).
// Beim Create werden alle Verträge, die den Vorgänger als aktuellen
// Zähler nutzen, automatisch auf den neuen Zähler umgestellt
// (ContractMeter-Eintrag analog zu Vertragsansicht).
successorOf?: {
predecessorMeterId: number;
installedAt?: string;
finalReadingPrevious?: number;
// Default true im UI: alter Zähler wird nach dem Wechsel auf
// isActive=false gesetzt. Kann ausgeschaltet werden, wenn der alte
// Zähler aus irgendeinem Grund noch aktiv bleiben soll.
deactivatePredecessor?: boolean;
};
}
) {
return prisma.meter.create({
if (data.addressId == null) {
throw new Error('Lieferadresse ist erforderlich');
}
await assertDeliveryAddressBelongsToCustomer(data.addressId, customerId);
// Vorgänger validieren (wenn Folgezähler)
let predecessor: { id: number; customerId: number; type: 'ELECTRICITY' | 'GAS' } | null = null;
if (data.successorOf) {
const pred = await prisma.meter.findUnique({
where: { id: data.successorOf.predecessorMeterId },
select: { id: true, customerId: true, type: true },
});
if (!pred || pred.customerId !== customerId) {
throw new Error('Vorgänger-Zähler nicht gefunden');
}
if (pred.type !== data.type) {
throw new Error('Vorgänger-Zähler muss denselben Typ haben (Strom/Gas)');
}
predecessor = pred;
// Endstand bereits hier validieren, damit kein verwaister Meter entsteht
// wenn der Wert mit bestehenden Zählerständen kollidiert.
if (data.successorOf.finalReadingPrevious != null) {
const switchAt = data.successorOf.installedAt
? new Date(data.successorOf.installedAt)
: new Date();
await validateReadingValue(
pred.id,
switchAt,
data.successorOf.finalReadingPrevious,
undefined,
'HT',
);
}
}
const created = await prisma.meter.create({
data: {
customerId,
...data,
meterNumber: data.meterNumber,
type: data.type,
tariffModel: data.tariffModel,
location: data.location,
addressId: data.addressId,
isActive: true,
predecessorMeterId: predecessor?.id,
},
include: { address: true, predecessor: true },
});
// Folgezähler-Propagation: alle Verträge, die den Vorgänger als aktuellen
// Zähler nutzen, bekommen den neuen Zähler als Nachfolger angehängt
// (analog zu addSuccessorMeter im contract.controller).
if (predecessor && data.successorOf) {
const installedAt = data.successorOf.installedAt
? new Date(data.successorOf.installedAt)
: new Date();
const finalReading = data.successorOf.finalReadingPrevious;
const affectedContracts = await prisma.energyContractDetails.findMany({
where: { meterId: predecessor.id },
include: { contractMeters: { orderBy: { position: 'asc' } } },
});
for (const ecd of affectedContracts) {
// Vorhandenen ContractMeter für den Vorgänger als gewechselt markieren.
// Falls noch kein ContractMeter für den Vorgänger existiert (Single-Meter-
// Vertrag vor Multi-Meter-Refactor), legen wir ihn als position 0 an,
// damit die Kette lückenlos ist.
let predCM = ecd.contractMeters.find((cm) => cm.meterId === predecessor!.id);
if (!predCM) {
predCM = await prisma.contractMeter.create({
data: {
energyContractDetailsId: ecd.id,
meterId: predecessor.id,
position: 0,
installedAt: null,
},
});
ecd.contractMeters.push(predCM);
}
await prisma.contractMeter.update({
where: { id: predCM.id },
data: {
removedAt: installedAt,
finalReading: finalReading != null ? finalReading : predCM.finalReading,
},
});
const nextPosition = ecd.contractMeters.length > 0
? Math.max(...ecd.contractMeters.map((cm) => cm.position)) + 1
: 0;
// Idempotenz: falls (durch Doppel-Klick o.ä.) schon ein ContractMeter
// mit dem neuen Zähler existiert, nicht doppelt anlegen.
const existsForNew = await prisma.contractMeter.findUnique({
where: {
energyContractDetailsId_meterId: {
energyContractDetailsId: ecd.id,
meterId: created.id,
},
},
});
if (!existsForNew) {
await prisma.contractMeter.create({
data: {
energyContractDetailsId: ecd.id,
meterId: created.id,
position: nextPosition,
installedAt,
},
});
}
// Aktuellen Zähler am Vertrag aktualisieren
await prisma.energyContractDetails.update({
where: { id: ecd.id },
data: { meterId: created.id },
});
}
// Endstand des Vorgängers als regulären Zählerstand erfassen, damit er
// in die Verbrauchsberechnung einfließt und in der Zählerstände-Liste
// sichtbar ist. Idempotent gegen Doppel-Submit.
if (data.successorOf.finalReadingPrevious != null) {
await recordPredecessorFinalReading(
predecessor.id,
installedAt,
data.successorOf.finalReadingPrevious,
);
}
// Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht
// explizit auf false setzt. Macht den typischen Zählerwechsel-Workflow
// ein-klick-fähig.
if (data.successorOf.deactivatePredecessor !== false) {
await prisma.meter.update({
where: { id: predecessor.id },
data: { isActive: false },
});
}
}
return created;
}
export async function updateMeter(
@@ -434,13 +664,21 @@ export async function updateMeter(
data: {
meterNumber?: string;
type?: 'ELECTRICITY' | 'GAS';
tariffModel?: 'SINGLE' | 'DUAL';
location?: string;
isActive?: boolean;
addressId?: number | null;
}
) {
if (data.addressId !== undefined && data.addressId !== null) {
const meter = await prisma.meter.findUnique({ where: { id }, select: { customerId: true } });
if (!meter) throw new Error('Zähler nicht gefunden');
await assertDeliveryAddressBelongsToCustomer(data.addressId, meter.customerId);
}
return prisma.meter.update({
where: { id },
data,
include: { address: true },
});
}
@@ -2,6 +2,22 @@
import prisma from '../../lib/prisma.js';
import { decrypt } from '../../utils/encryption.js';
import { stripHtml } from '../../utils/sanitize.js';
// Pentest 48.1 (MEDIUM, 2026-06-01): customerEmailLabel landete roh in der
// DB und kam über /api/email-providers/public-settings 1:1 raus. React
// escapt zwar als Textnode, aber Defense-in-Depth verlangt Stripping schon
// beim Schreiben (PDF/Mail-Templates wären sofort betroffen). Zudem war
// das Längenlimit nur frontendseitig gesetzt hier 60 Zeichen enforced.
const CUSTOMER_EMAIL_LABEL_MAX = 60;
function sanitizeCustomerEmailLabel(raw: unknown): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') return null;
const stripped = stripHtml(raw) as string;
const trimmed = stripped.trim();
if (trimmed === '') return null;
return trimmed.slice(0, CUSTOMER_EMAIL_LABEL_MAX);
}
import {
IEmailProvider,
EmailProviderConfig,
@@ -126,7 +142,7 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
systemEmailAddress: data.systemEmailAddress || null,
systemEmailPasswordEncrypted,
customerEmailLabel: data.customerEmailLabel || null,
customerEmailLabel: sanitizeCustomerEmailLabel(data.customerEmailLabel),
isActive: data.isActive ?? true,
isDefault: data.isDefault ?? false,
},
@@ -159,7 +175,7 @@ export async function updateProviderConfig(
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
if (data.customerEmailLabel !== undefined) updateData.customerEmailLabel = data.customerEmailLabel?.trim() || null;
if (data.customerEmailLabel !== undefined) updateData.customerEmailLabel = sanitizeCustomerEmailLabel(data.customerEmailLabel);
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
@@ -532,7 +548,10 @@ export async function getProviderPublicSettings(): Promise<{
}> {
const config = await getActiveProviderConfig();
const domain = config?.domain ?? null;
const customLabel = config?.customerEmailLabel?.trim();
// Read-Time-Defensive (Pentest 48.1): falls je rohe Alt-Daten in der DB
// landeten, hier nochmal durch den Sanitizer schicken. Stellt sicher,
// dass /public-settings nicht ungewollt XSS-Payloads rausreicht.
const customLabel = sanitizeCustomerEmailLabel(config?.customerEmailLabel) ?? null;
return {
domain,
@@ -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,
},
});
+45 -6
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' },
@@ -87,7 +90,9 @@ export const CRM_FIELDS = [
{ path: 'energyDetails.basePrice', label: 'Grundpreis (€/Monat)', group: 'Energie' },
{ path: 'energyDetails.unitPrice', label: 'Arbeitspreis (€/kWh)', group: 'Energie' },
{ path: 'energyDetails.unitPriceNt', label: 'NT-Arbeitspreis (€/kWh)', group: 'Energie' },
{ path: 'energyDetails.bonus', label: 'Bonus (€)', group: 'Energie' },
{ path: 'energyDetails.instantBonus', label: 'Sofort-Bonus (€)', group: 'Energie' },
{ path: 'energyDetails.newCustomerBonus', label: 'Neukunden-Bonus (€)', group: 'Energie' },
{ path: 'energyDetails.totalBonus', label: 'Gesamtbonus (€)', group: 'Energie' },
// Internet/DSL/Glasfaser/Kabel
{ path: 'internetDetails.downloadSpeed', label: 'Download-Speed (Mbit/s)', group: 'Internet' },
{ path: 'internetDetails.uploadSpeed', label: 'Upload-Speed (Mbit/s)', group: 'Internet' },
@@ -384,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
@@ -435,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 || '',
@@ -469,7 +481,11 @@ export async function generateFilledPdf(
'energyDetails.basePrice': contract.energyDetails?.basePrice?.toString() || '',
'energyDetails.unitPrice': contract.energyDetails?.unitPrice?.toString() || '',
'energyDetails.unitPriceNt': contract.energyDetails?.unitPriceNt?.toString() || '',
'energyDetails.bonus': contract.energyDetails?.bonus?.toString() || '',
'energyDetails.instantBonus': contract.energyDetails?.instantBonus?.toString() || '',
'energyDetails.newCustomerBonus': contract.energyDetails?.newCustomerBonus?.toString() || '',
'energyDetails.totalBonus': (
((contract.energyDetails?.instantBonus ?? 0) + (contract.energyDetails?.newCustomerBonus ?? 0)) || ''
).toString(),
// Internet
'internetDetails.downloadSpeed': contract.internetDetails?.downloadSpeed?.toString() || '',
'internetDetails.uploadSpeed': contract.internetDetails?.uploadSpeed?.toString() || '',
@@ -555,8 +571,31 @@ export async function generateFilledPdf(
const maxFields = template.maxPhoneFields || 8;
for (let i = 0; i < Math.max(maxFields, phoneNumbers.length); i++) {
const fullNumber = phoneNumbers[i]?.phoneNumber || '';
const { areaCode, local } = splitPhoneNumber(fullNumber);
const entry = phoneNumbers[i];
const fullNumber = entry?.phoneNumber || '';
// Bevorzugt den explizit gepflegten areaCode aus der DB (verlässlich),
// fällt sonst auf die Heuristik zurück (Altbestand ohne separates
// Vorwahl-Feld). `phoneLocal` analog: aus phoneNumber abgeleitet,
// wenn areaCode da → den Vorwahl-Prefix abschneiden, sonst Heuristik.
let areaCode = '';
let local = '';
if (entry?.areaCode) {
areaCode = entry.areaCode;
const split = splitPhoneNumber(fullNumber);
// Wenn der heuristische areaCode mit dem DB-Wert übereinstimmt,
// ist der heuristische local-Anteil korrekt sonst pragmatisch:
// alles nach dem areaCode-Prefix bis zum Ende
if (split.areaCode === entry.areaCode) {
local = split.local;
} else {
const stripped = fullNumber.replace(new RegExp('^' + entry.areaCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\-/]*'), '').trim();
local = stripped || split.local;
}
} else {
const split = splitPhoneNumber(fullNumber);
areaCode = split.areaCode;
local = split.local;
}
dataContext[`phoneNumbers[${i}]`] = fullNumber;
dataContext[`phoneAreaCode[${i}]`] = areaCode;
dataContext[`phoneLocal[${i}]`] = local;
+94 -14
View File
@@ -1,4 +1,20 @@
import prisma from '../lib/prisma.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.
// PUT akzeptierte `javascript:alert(...)` als URL, das Portal rendert
// sie als <a href={portalUrl}> → ein Klick im Kunden-Browser löst die
// XSS aus. Fix: vor Schreiben durch validateHttpUrl, das auch andere
// gefährliche Schemata (data:, vbscript:, file:) sperrt und private
// IPs verbietet (die URL wird Kunden gezeigt, denen interne Hosts
// nichts bringen).
function assertValidPortalUrl(portalUrl: string | undefined | null): string | undefined {
if (portalUrl == null || portalUrl === '') return undefined;
const check = validateHttpUrl(portalUrl, { fieldLabel: 'Portal-URL' });
if (!check.ok) throw new Error(check.error);
return check.value === '' ? undefined : check.value;
}
export async function getAllProviders(includeInactive = false) {
const where = includeInactive ? {} : { isActive: true };
@@ -31,33 +47,97 @@ export async function getProviderById(id: number) {
});
}
export async function createProvider(data: {
name: string;
// Pentest 47.2 (INFO, 2026-06-01): provider.name landete roh in der DB.
// 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.
//
// 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 };
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;
}
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: {
...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: any = stripProviderStrings(data);
if (data.portalUrl !== undefined) {
const validated = assertValidPortalUrl(data.portalUrl);
updateData.portalUrl = validated ?? null;
}
) {
return prisma.provider.update({
where: { id },
data,
data: updateData,
});
}
+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
+28
View File
@@ -130,6 +130,34 @@ export async function canAccessCustomer(
return true;
}
/**
* Liefert die Liste aller Customer-IDs, auf die ein Portal-User aktuell
* Zugriff hat: eigene + vertretene MIT aktiver Vollmacht (Live-Check via
* `authorizationService.hasAuthorization`). Für Nicht-Portal-User wird
* `null` zurückgegeben (= kein Filter, alle Kunden erlaubt).
*
* Diese Funktion fängt einen wiederkehrenden Pentest-Befund ab: ohne den
* Live-Vollmacht-Check hätte ein Portal-User mit widerrufener Vollmacht
* weiterhin Zugriff auf die Daten des vertretenen Kunden, nur weil seine
* `representedCustomerIds` im JWT noch drin sind (Token kann bis zu
* 15min alt sein).
*/
export async function getPortalAllowedCustomerIds(
req: AuthRequest,
): Promise<number[] | null> {
if (!req.user?.isCustomerPortal || !req.user.customerId) return null;
const allowed: number[] = [req.user.customerId];
const represented: number[] = (req.user as any).representedCustomerIds || [];
for (const repCustId of represented) {
const hasAuth = await authorizationService.hasAuthorization(
repCustId,
req.user.customerId,
);
if (hasAuth) allowed.push(repCustId);
}
return allowed;
}
/**
* Generische Zugriffsprüfung: Ressource customerId canAccessCustomer.
*/
+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;
}
}
+6 -2
View File
@@ -4,8 +4,12 @@ export function generateCustomerNumber(): string {
return `K${timestamp}${random}`;
}
export function generateContractNumber(type: string): string {
const prefix = type.substring(0, 3).toUpperCase();
export function generateContractNumber(type: string | null | undefined): string {
// Defensiv: ohne validen Type-String fällt der Prefix auf "CON" zurück.
// Pentest Runde 12: POST /contracts ohne `type` warf
// "Cannot read properties of undefined (reading 'substring')".
const safeType = (typeof type === 'string' && type.length > 0) ? type : 'CON';
const prefix = safeType.substring(0, 3).toUpperCase();
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 5).toUpperCase();
return `${prefix}-${timestamp}${random}`;
+49
View File
@@ -88,6 +88,55 @@ export function generateSimplePassword(length = 12): string {
});
}
// ==================== PASSWORD COMPLEXITY VALIDATION ====================
/**
* Mindestanforderungen für vom User vergebene Passwörter.
* Generator-Output (generateSecurePassword) erfüllt diese standardmäßig.
*/
export interface PasswordComplexityResult {
ok: boolean;
errors: string[];
}
// Mindestlängen nach Kontext (Pentest Runde 13 / 2026-05-18):
// Endkunden tippen ihr Portal-Passwort auch auf dem Handy ein 12 ist hier
// der Endkunden-Floor. Mitarbeiter/Admin nutzen Passwort-Manager → 25
// Zeichen entsprechen der aktuellen BSI-Empfehlung für lange Passphrasen
// mit Komplexität.
export const PORTAL_MIN_PASSWORD_LENGTH = 12;
export const STAFF_MIN_PASSWORD_LENGTH = 25;
export function validatePasswordComplexity(
pw: unknown,
opts: { minLength?: number } = {},
): PasswordComplexityResult {
const minLength = opts.minLength ?? PORTAL_MIN_PASSWORD_LENGTH;
const errors: string[] = [];
if (typeof pw !== 'string') {
return { ok: false, errors: ['Passwort fehlt oder ist kein Text'] };
}
if (pw.length < minLength) errors.push(`mindestens ${minLength} Zeichen`);
if (!/[a-z]/.test(pw)) errors.push('mindestens einen Kleinbuchstaben');
if (!/[A-Z]/.test(pw)) errors.push('mindestens einen Großbuchstaben');
if (!/[0-9]/.test(pw)) errors.push('mindestens eine Ziffer');
// Sonderzeichen-Set bewusst breit auch Leerzeichen + Unicode-Punktuation
// zulassen, damit gängige Passwort-Manager-Outputs nicht abgelehnt werden.
if (!/[^A-Za-z0-9]/.test(pw)) errors.push('mindestens ein Sonderzeichen');
return { ok: errors.length === 0, errors };
}
/**
* Wirft mit sprechender Fehlermeldung, wenn das Passwort die Komplexität
* nicht erfüllt. Für Aufruf direkt im Controller, der die Exception fängt.
*/
export function assertPasswordComplexity(pw: unknown, opts: { minLength?: number } = {}): void {
const r = validatePasswordComplexity(pw, opts);
if (!r.ok) {
throw new Error('Passwort erfüllt Mindestanforderungen nicht: ' + r.errors.join(', '));
}
}
// Kryptografisch sichere Zufallszahl
function getRandomInt(max: number): number {
const bytes = randomBytes(4);
+690 -13
View File
@@ -4,11 +4,109 @@
* 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',
'portalPasswordResetToken',
'portalPasswordResetExpiresAt',
// consentHash ist ein Pseudo-Credential für den öffentlichen Consent-Link
// (jeder mit dem Hash kann Einwilligungen erteilen + Name/Kundennummer
// anzeigen). Über GET /customers/:id darf es nicht raus. Wer ihn legitim
// 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
// Auth-Bypass, aber unnötig. Wenn Admin diese Information legitim
// braucht (z.B. UI-Hinweis "OTP wurde noch nicht eingelöst"), führen
// wir bei Bedarf einen eigenen Endpoint ein.
'portalPasswordMustChange',
'portalTokenInvalidatedAt',
] as const;
// Zusätzliche Felder die Portal-User nicht in ihrer Customer-Response sehen
// sollen Interne Session-/Workflow-State, kein direkter Auth-Bypass, aber
// unnötige Informationsleckage über den DB-Aufbau.
// Pentest Runde 7 (2026-05-17), MEDIUM.
const PORTAL_HIDDEN_CUSTOMER_FIELDS = [
// portalTokenInvalidatedAt + portalPasswordMustChange sind jetzt in
// SENSITIVE_CUSTOMER_FIELDS (immer raus), nicht mehr nur für Portal.
'portalLastLogin',
'lastBirthdayGreetingYear',
// privacyPolicyPath etc. sind interne Datei-Pfade Portal nutzt
// dedizierte PDF-Endpoints, nicht den Pfad direkt
'privacyPolicyPath',
'businessRegistrationPath',
'commercialRegisterPath',
// Pentest Runde 10 (2026-05-17): notes sind interne CRM-Vermerke
// ("Kunde ist schwierig" etc.) und gehören nicht in die Portal-Sicht.
'notes',
] as const;
// Felder die im Contract NIE rausgehen dürfen (auch nicht an Mitarbeiter).
// portalPasswordEncrypted ist nur über den dedizierten /password-Endpoint
// (mit Audit-Log) abrufbar im /contracts/:id selbst nutzlos.
const SENSITIVE_CONTRACT_FIELDS = [
'portalPasswordEncrypted',
] as const;
// Zusätzliche Felder die Portal-User nicht sehen sollen (interne CRM-Daten).
// Pentest Runde 7 (2026-05-17): commission + notes leakten an Portal-User.
const PORTAL_HIDDEN_CONTRACT_FIELDS = [
'commission',
'notes',
'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
] as const;
// User-eingabe String-Felder am Contract, die in der UI dargestellt werden.
// Werden beim Read über stripHtml geschickt, damit Alt-Daten mit rohen
// XSS-Payloads (vor Einführung von sanitizeContractBody) nicht mehr als
// `<script>alert(...)</script>` in der Liste auftauchen. Neue Daten sind
// schon beim Write gestrippt, aber doppelt hält besser.
const CONTRACT_DISPLAY_STRING_FIELDS = [
'providerName',
'tariffName',
'customerNumberAtProvider',
'contractNumberAtProvider',
'orderNumberAtSalesPlatform',
'customerNumberAtSalesPlatform',
'contractNumberAtSalesPlatform',
'portalUsername',
'previousProviderName',
'previousCustomerNumber',
'previousContractNumber',
'notes',
// Preisfelder sind im Schema `String?` (freitextlich, nicht numerisch),
// damit Tarifangaben wie "0,28 €/kWh" oder "27,90 € + 10 € Bonus"
// möglich sind. Pentest 2026-05-30 (MEDIUM, 42.5): rohe HTML-Payloads
// in den drei Feldern überlebten den Write-Strip nicht und kommen
// beim Read 1:1 wieder raus.
'priceFirst12Months',
'priceFrom13Months',
'priceAfter24Months',
] as const;
// User-eingabe String-Felder am Customer für dieselbe Read-Time-Defensive.
const CUSTOMER_DISPLAY_STRING_FIELDS = [
'firstName',
'lastName',
'companyName',
'salutation',
'email',
'phone',
'mobile',
'portalEmail',
'portalUsername',
'taxNumber',
'commercialRegisterNumber',
'notes',
] as const;
const SENSITIVE_USER_FIELDS = [
@@ -22,29 +120,46 @@ const SENSITIVE_USER_FIELDS = [
* Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt.
* `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat
* (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt
* es standardmäßig.
* es standardmäßig. Embedded `contracts[]` werden ebenfalls sanitisiert
* (Pentest Runde 10 DTO-Leak in eingebetteten Objekten).
*/
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = { ...customer };
const copy: Record<string, unknown> = { ...customer };
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
delete copy[field];
}
for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) {
if (typeof copy[field] === 'string') {
copy[field] = stripForDisplay(copy[field]);
}
}
if (Array.isArray(copy.contracts)) {
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContract(c));
}
// portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das
// Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission
// auf 'customers:update' implizit gesichert.
return copy;
return copy as T;
}
/**
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
* Entfernt portalPasswordEncrypted + portal-interne Workflow-Felder zusätzlich
* zu den allgemein sensiblen Feldern. Für Kontexte in denen der Caller KEIN
* Admin ist (z.B. Portal-Kunde). Embedded `contracts[]` werden mit der
* Strict-Variante sanitisiert.
*/
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
if (!copy) return null;
delete copy.portalPasswordEncrypted;
for (const field of PORTAL_HIDDEN_CUSTOMER_FIELDS) {
delete copy[field];
}
if (Array.isArray(copy.contracts)) {
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContractStrict(c));
}
return copy as T;
}
@@ -55,6 +170,417 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
}
/**
* Sanitize Contract-Objekt für alle Caller. Entfernt das verschlüsselte
* 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
// oder Preisfeld ist er nur kosmetischer Müll.
// Pentest 2026-05-30 (INFO, 43.5): `javascript:alert(1)` in
// priceFirst12Months wurde als "blocked:alert(1)" angezeigt.
function stripForDisplay(value: unknown): unknown {
const stripped = stripHtml(value);
if (typeof stripped === 'string' && stripped.includes('blocked:')) {
return stripped.replace(/blocked:/g, '').trim();
}
return stripped;
}
export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
if (!contract) return contract;
const copy: Record<string, unknown> = { ...contract };
for (const field of SENSITIVE_CONTRACT_FIELDS) {
delete copy[field];
}
for (const field of CONTRACT_DISPLAY_STRING_FIELDS) {
if (typeof copy[field] === 'string') {
copy[field] = stripForDisplay(copy[field]);
}
}
// Nested: previousProviderName liegt im energyDetails-Sub-Objekt
if (copy.energyDetails && typeof copy.energyDetails === 'object') {
const ed = copy.energyDetails as Record<string, unknown>;
if (typeof ed.previousProviderName === 'string') {
ed.previousProviderName = stripHtml(ed.previousProviderName);
}
if (typeof ed.previousCustomerNumber === 'string') {
ed.previousCustomerNumber = stripHtml(ed.previousCustomerNumber);
}
}
// Nested: previousContract wird rekursiv auch sanitisiert
if (copy.previousContract && typeof copy.previousContract === 'object') {
copy.previousContract = sanitizeContract(copy.previousContract as Record<string, unknown>);
}
if (copy.customer && typeof copy.customer === 'object') {
copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
}
return copy as T;
}
/**
* Sanitize Contract für Portal-User: zusätzlich werden interne CRM-Felder
* (Provision, Notizen, Snooze-Date) gestrippt und das embedded customer
* mit `sanitizeCustomerStrict` gefiltert. Pentest Runde 7 (2026-05-17).
*/
export function sanitizeContractStrict<T extends Record<string, unknown>>(contract: T | null): T | null {
if (!contract) return contract;
const copy = sanitizeContract(contract) as Record<string, unknown> | null;
if (!copy) return null;
for (const field of PORTAL_HIDDEN_CONTRACT_FIELDS) {
delete copy[field];
}
if (copy.customer && typeof copy.customer === 'object') {
copy.customer = sanitizeCustomerStrict(copy.customer as Record<string, unknown>);
}
return copy as T;
}
export function sanitizeContracts<T extends Record<string, unknown>>(contracts: T[]): T[] {
return contracts.map((c) => sanitizeContract(c)).filter((c): c is T => c !== null);
}
export function sanitizeContractsStrict<T extends Record<string, unknown>>(contracts: T[]): T[] {
return contracts.map((c) => sanitizeContractStrict(c)).filter((c): c is T => c !== null);
}
/**
* Sanitize User-Objekt für API-Responses.
*/
@@ -109,7 +635,6 @@ const USER_UPDATABLE_FIELDS = [
'telegramUsername',
'signalNumber',
'roleIds',
'password', // nur Admin, wird im Service gehashed
// hasGdprAccess + hasDeveloperAccess sind keine User-Spalten der Service
// mappt sie auf die versteckten Rollen DSGVO/Developer (siehe
// setUserGdprAccess / setUserDeveloperAccess). Müssen aber auf der Whitelist
@@ -117,36 +642,188 @@ const USER_UPDATABLE_FIELDS = [
'hasGdprAccess',
'hasDeveloperAccess',
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
// Nicht: password wird über dedizierten Endpoint POST /users/:id/password
// gesetzt (Pentest Runde 12 (2026-05-18) MITTEL: generisches User-Update
// hatte password in der Whitelist, ein Admin konnte stillschweigend ohne
// dedizierten Audit-Trail Passwörter überschreiben).
] as const;
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
// Bei CREATE braucht's das initial-Passwort
const USER_CREATE_FIELDS = [
...USER_UPDATABLE_FIELDS,
'password',
] as const;
/**
* Strippt HTML-Tags und Script-/Style-Inhalt aus einem String, damit ein
* gespeicherter Wert nicht später irgendwo zum aktiven XSS-Vektor wird
* (z.B. PDF-Generator, E-Mail-Template oder ein dangerouslySetInnerHTML
* im Frontend). React-Auto-Escaping fängt den normalen Fall ab, aber
* Defense-in-Depth speichert lieber gleich nichts Bösartiges.
*
* Verlauf:
* - Pentest Runde 11 (2026-05-18): <script> in companyName.
* - Pentest 2026-05-20 (28.1/28.2): URI-Schema-Liste + Entity-Decoding.
* - Pentest 2026-05-20 (29.1/29.2/29.3): Zero-Width-Chars,
* Percent-Encoding und Cyrillic-Homoglyph-Bypass. Reihenfolge ist
* wichtig: zuerst Unicode-Müll raus, dann percent + entity dekodieren,
* dann Homoglyphe normalisieren, ZULETZT Tag-/Scheme-Strip.
* - Pentest 2026-05-20 (28.1 Restarbeit): blob:/about:/ws:/wss:/
* ldap:/dict: ergänzt.
*/
// Schemes die wir aktiv blocken durch "blocked:" ersetzen statt
// löschen, damit legitimer Text drumherum erhalten bleibt.
// Bewusst nicht in der Liste: http(s):, mailto:, tel: (legitime URLs in
// Notizfeldern). Alles andere geht selten in einem Plain-Text-Feld vor
// und kann im worst case immer noch durch JS interpretiert werden.
const DANGEROUS_URI_SCHEMES =
/(?:javascript|data|vbscript|file|ftp|blob|about|ws|wss|ldap|dict)\s*:/gi;
// Unsichtbare Unicode-Steuerzeichen, die wie Whitespace fehlen, aber
// Regex-Matches auf "javascript:" zerteilen können. Plain-Text-Felder
// enthalten diese nie legitim. Pentest 29.3.
// U+200BU+200F (ZWSP, ZWNJ, ZWJ, LRM, RLM), U+202AU+202E (Embedding/
// Override), U+2060U+2064 (Word-Joiner & co.), U+FEFF (BOM).
const ZERO_WIDTH_CHARS = /[---]/g;
// Cyrillic/Greek-Homoglyphe, die in URL-Schemes als Spoofing taugen
// (Pentest 29.1: "jаvascript:" mit kyrillischem а = U+0430).
// Bewusst eng gehalten: nur die Buchstaben, die in JS-/HTML-Schlüssel-
// wörtern vorkommen wir wollen legitimes Russisch/Griechisch (z.B. in
// einem Notizfeld) nicht komplett zerlegen. Das Risiko, dass ein
// einzelnes "а" in einem ru-Wort versehentlich zu "a" wird, ist
// akzeptabel die häufiger genutzten cyrillischen Buchstaben (б, в,
// г, …) sind nicht in der Map.
const HOMOGLYPH_TO_ASCII: Record<string, string> = {
'а': 'a', 'А': 'A', // U+0430 / U+0410
'е': 'e', 'Е': 'E', // U+0435 / U+0415
'о': 'o', 'О': 'O', // U+043E / U+041E
'р': 'p', 'Р': 'P', // U+0440 / U+0420
'с': 'c', 'С': 'C', // U+0441 / U+0421
'у': 'y', 'У': 'Y', // U+0443 / U+0423
'х': 'x', 'Х': 'X', // U+0445 / U+0425
'і': 'i', 'І': 'I', // U+0456 / U+0406 (Ukr.)
'ј': 'j', 'Ј': 'J', // U+0458 / U+0408 (Mac.)
'ѕ': 's', 'Ѕ': 'S', // U+0455 / U+0405
'ο': 'o', 'Ο': 'O', // U+03BF / U+039F (Greek)
'α': 'a', // U+03B1
};
const HOMOGLYPH_RE = new RegExp(Object.keys(HOMOGLYPH_TO_ASCII).join('|'), 'g');
function decodeHtmlEntities(s: string): string {
return s
.replace(/&#(\d+);?/g, (_m, code) => {
const n = parseInt(code, 10);
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
})
.replace(/&#x([0-9a-fA-F]+);?/g, (_m, code) => {
const n = parseInt(code, 16);
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
})
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#?apos;/gi, "'")
.replace(/&amp;/gi, '&');
}
// Percent-decoded String. Iterativ bis stabil ein Angreifer kann
// "java%2573cript:" schreiben (`%25` ist `%`), das nach einer
// Iteration zu "java%73cript:" wird, was wiederum zu "javascript:".
// Max 5 Iterationen reichen für realistische Verschachtelungen.
// `decodeURIComponent` würde bei ungültigen Sequenzen werfen, deshalb
// machen wir es per Regex.
function percentDecode(s: string): string {
let prev = s;
for (let i = 0; i < 5; i++) {
const next = prev.replace(/%([0-9A-Fa-f]{2})/g, (_m, hex) =>
String.fromCharCode(parseInt(hex, 16)),
);
if (next === prev) return next;
prev = next;
}
return prev;
}
/**
* Strikte Email-Validierung. Wichtig vor allem für **SMTP-Header-
* Injection**: ein gespeicherter Wert wie `test@x.de\nBcc:attacker@evil.de`
* würde, wenn er in einen To-Header geschrieben wird, einen zusätzlichen
* Bcc-Empfänger einschleusen (Pentest 29.4). Wir verbieten daher:
* - Whitespace, Newlines, Tabs, Steuerzeichen
* - mehr als ein `@`
* - Domain ohne Punkt
* - Länge > 254 (RFC 5321)
* Format: `local@domain.tld`, local 164 ASCII, domain DNS-konform.
*
* `null`/leer ist erlaubt (Email ist oft optional). Aufrufer entscheidet,
* ob `null` ok ist.
*/
export function isValidEmail(value: unknown): boolean {
if (value === null || value === undefined || value === '') return true;
if (typeof value !== 'string') return false;
if (value.length > 254) return false;
// Newline/Tab/Steuerzeichen explizit ablehnen das ist der
// Header-Injection-Vektor.
if (/[\r\n\t\0\v\f]/.test(value)) return false;
// Basic RFC-5322-ähnlich, ohne quoted-string-Local-Part.
// Local-Part: 164 Zeichen aus [a-z0-9._%+-], muss nicht mit Punkt
// beginnen/enden. Domain: Labels mit Punkten, TLD mind. 2 Zeichen.
return /^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(value);
}
export function stripHtml(value: unknown): unknown {
if (typeof value !== 'string') return value;
let s = value;
// 1) Unicode-Steuerzeichen raus, sonst zerteilen sie Regex-Matches.
s = s.replace(ZERO_WIDTH_CHARS, '');
// 2) Percent-Encoding auflösen (iterativ bis stabil).
s = percentDecode(s);
// 3) Homoglyphe normalisieren, damit "jаvascript:" zu "javascript:" wird.
s = s.replace(HOMOGLYPH_RE, (m) => HOMOGLYPH_TO_ASCII[m] || m);
// 4) HTML-Entities dekodieren.
s = decodeHtmlEntities(s);
// 5) Tags + dangerous Schemes strippen.
s = s
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(DANGEROUS_URI_SCHEMES, 'blocked:');
return s;
}
/**
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
* Optional werden alle String-Werte durch stripHtml geschickt.
*/
function pick<T extends object>(obj: T, allowed: readonly string[]): Partial<T> {
function pick<T extends object>(obj: T, allowed: readonly string[], options: { stripHtmlFromStrings?: boolean } = {}): Partial<T> {
const result: Partial<T> = {};
for (const key of allowed) {
if (key in obj) {
(result as any)[key] = (obj as any)[key];
let v = (obj as any)[key];
if (options.stripHtmlFromStrings && typeof v === 'string') {
v = stripHtml(v);
}
(result as any)[key] = v;
}
}
return result;
}
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS);
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS);
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS, { stripHtmlFromStrings: true });
}
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, USER_UPDATABLE_FIELDS);
return pick((body as object) || {}, USER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, USER_CREATE_FIELDS);
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
}
+169 -7
View File
@@ -23,18 +23,118 @@ const BLOCKED_PATTERNS: RegExp[] = [
/^23[0-9]\./, // 230-239 Multicast
/^24[0-9]\./, // 240-249 reserved
/^25[0-5]\./, // 250-255 reserved
// Pentest 51.2 (LOW, 2026-06-01): 100.64.0.0/10 CGNAT (RFC 6598)
// wird teils von Cloud-Providern für interne Pfade genutzt; 100.100.x.x
// ist konkret Alibaba Cloud Metadata.
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
/^fd00:ec2::/i, // AWS IPv6 Metadata
/^fe80:/i, // IPv6 Link-Local
// Pentest 51.1: lang ausgeschriebene fe80-Form abdecken
/^fe80:0*:/i,
/^ff/i, // IPv6 Multicast
];
// Opt-in für Cloud-Deployments: ALLE privaten IP-Ranges blocken, nicht
// nur Cloud-Metadata. On-Prem-Default ist `false`, weil On-Prem-Setups
// häufig Plesk/Dovecot/Postfix auf 127.0.0.1 oder im internen Netz
// laufen lassen. (Pentest 2026-05-20 INFO 30.14.) Aktivieren mit
// `SSRF_BLOCK_PRIVATE_IPS=true` in der Umgebung.
const BLOCK_PRIVATE_IPS = (process.env.SSRF_BLOCK_PRIVATE_IPS || '').toLowerCase() === 'true';
const PRIVATE_IP_PATTERNS: RegExp[] = [
/^127\./, // 127.0.0.0/8 Loopback
/^10\./, // 10.0.0.0/8
/^192\.168\./, // 192.168.0.0/16
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
// IPv6 Loopback in allen Schreibweisen, die DNS/URL-Parser liefern können
/^::1$/, // kompakte Form
/^0:0:0:0:0:0:0:1$/, // voll ausgeschrieben
/^::ffff:127\./i, // IPv4-mapped Loopback (kompakt)
/^0:0:0:0:0:ffff:127\./i, // IPv4-mapped Loopback (ausgeschrieben)
/^::ffff:10\./i, // IPv4-mapped 10/8
/^0:0:0:0:0:ffff:10\./i,
/^::ffff:192\.168\./i, // IPv4-mapped 192.168/16
/^0:0:0:0:0:ffff:192\.168\./i,
/^::ffff:172\.(1[6-9]|2\d|3[01])\./i,
/^0:0:0:0:0:ffff:172\.(1[6-9]|2\d|3[01])\./i,
// Pentest 51.1 (MEDIUM, 2026-06-01): fc00::/7 deckt fc00..fdff ab
// (Unique-Local + Site-Local). Das alte `/^f[cd]/i` greift nur am
// Anfang einer einzelnen Hex-Stelle; lang ausgeschriebene Formen
// fingen wir nicht zuverlässig. Jetzt explizit auf das erste Group-
// Hex-Block-Prefix `fc` oder `fd` (gefolgt von 2 Hex + ':').
/^f[cd][0-9a-f]{2}:/i,
];
const PRIVATE_HOSTNAMES = new Set([
'localhost',
'ip6-localhost',
'ip6-loopback',
]);
const BLOCKED_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.goog',
'metadata',
'169.254.169.254',
// Pentest 51.2 (LOW, 2026-06-01): Alibaba Cloud Metadata
'100.100.100.200',
// Vollständig ausgeschriebene IPv6-Loopback und gängige Cloud-Provider-
// Hostnamen, die DNS-Auflösung in geblockten Ranges liefern würden.
'0:0:0:0:0:0:0:1',
'[::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();
@@ -43,16 +143,63 @@ 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) {
if (pattern.test(h)) return true;
}
}
return false;
}
/**
* Strikter Check: blockt private/loopback IP-Ranges UNABHÄNGIG von
* `SSRF_BLOCK_PRIVATE_IPS`. Für Use Cases, in denen ein privater Host
* NIE legitim sein kann z.B. eine URL, die an Endkunden per Mail
* geht (der Kunde kann eh nicht auf 192.168.x.x routen). Pentest
* 2026-05-28 Runde 35.
*
* Liefert true auch für die regulären Block-Patterns (Cloud-Metadata
* etc.), sodass Caller nur eine Funktion aufrufen müssen.
*/
export function isPrivateOrBlockedHost(host: string | null | undefined): boolean {
if (!host) return false;
const h = host.trim().toLowerCase();
if (!h) return false;
if (BLOCKED_HOSTNAMES.has(h)) return true;
if (PRIVATE_HOSTNAMES.has(h)) return true;
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(h)) return true;
}
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;
}
/**
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
* Caller sollte den Fehler in 400er Response umsetzen.
*
* `strict=true` (Pentest 50.1, 2026-06-01): private/Loopback-Ranges werden
* UNABHÄNGIG von `SSRF_BLOCK_PRIVATE_IPS` immer geblockt. Für Endpunkte mit
* besonders kritischer Angriffsfläche (test-connection, test-mail-access),
* die im Cloud-Deployment sonst Metadata-/Internal-Service-Probes erlauben
* würden.
*/
export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void {
if (isBlockedSsrfHost(host)) {
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
export function assertAllowedHost(host: string | null | undefined, label = 'Host', opts: { strict?: boolean } = {}): void {
const blocked = opts.strict ? isPrivateOrBlockedHost(host) : isBlockedSsrfHost(host);
if (blocked) {
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved / privater Host).`);
}
}
@@ -70,18 +217,33 @@ import net from 'net';
*
* Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft.
*/
export async function safeResolveHost(host: string | null | undefined, label = 'Host'): Promise<{ ip: string; servername: string }> {
export async function safeResolveHost(
host: string | null | undefined,
label = 'Host',
opts: { strict?: boolean } = {},
): Promise<{ ip: string; servername: string }> {
if (!host || !host.trim()) {
throw new Error(`${label} fehlt`);
}
const trimmed = host.trim();
// URL.hostname liefert IPv6-Hosts mit eckigen Klammern (`[::1]`).
// Damit `net.isIP` und die Regex-Pattern korrekt matchen, hier strippen.
// Pentest 51.1 (2026-06-01): ohne dieses Stripping fiel `::1` durch
// ins DNS-Branch und die Block-Patterns liefen ins Leere.
const trimmed = host.trim().replace(/^\[|\]$/g, '');
const check = opts.strict ? isPrivateOrBlockedHost : isBlockedSsrfHost;
// IP-Literal? Direkt prüfen, kein DNS nötig.
if (net.isIP(trimmed)) {
assertAllowedHost(trimmed, label);
assertAllowedHost(trimmed, label, opts);
return { ip: trimmed, servername: trimmed };
}
// Pentest 50.1 Defense-in-Depth: bereits vor DNS prüfen, ob der
// Hostname selbst auf der Blocklist steht (z.B. "metadata",
// "metadata.google.internal", "localhost"). DNS könnte sonst je nach
// Resolver legitime IPs liefern und so die Hostname-Blocklist umgehen.
assertAllowedHost(trimmed, label, opts);
// Hostname → resolve to IPv4 + IPv6
let ips: string[] = [];
try {
@@ -98,7 +260,7 @@ export async function safeResolveHost(host: string | null | undefined, label = '
// Alle aufgelösten IPs prüfen schon eine geblockte reicht für Ablehnung.
for (const ip of ips) {
if (isBlockedSsrfHost(ip)) {
if (check(ip)) {
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { isPrivateOrBlockedHost } from './ssrfGuard.js';
/**
* Zentrale Validierung für nach außen geleitete URLs (Portal-Links,
* Anbieter-Portale, Mail-Footer). Konsolidiert die Schema-/Host-Checks,
* die bisher pro Feld einzeln (und uneinheitlich) verstreut waren:
* - `appSetting.portalLoginUrl` hatte einen vollen Check
* - `provider.portalUrl` hatte gar keinen Stored XSS via
* `javascript:alert(...)` (Pentest 46.1 HIGH)
* - andere Felder strippten nur `<script>`-Tags
*
* Regelwerk:
* - Leer/null OK (Feld ist optional, keine Validierung)
* - Schema MUSS http oder https sein (keine `javascript:`,
* `data:`, `file:`, `vbscript:` )
* - Host muss vorhanden sein
* - Bei `allowPrivateHosts=false` (Default): Private/Loopback-IPs
* und Cloud-Metadata-Adressen sind gesperrt, weil die URL für
* Endkunden gedacht ist und 10.x/192.168.x für die ohnehin
* nicht erreichbar wären
* - Trailing-Slash wird gestrippt (Komfort beim Speichern)
*/
export function validateHttpUrl(
rawValue: string,
opts: { fieldLabel?: string; allowPrivateHosts?: boolean } = {},
): { ok: true; value: string } | { ok: false; error: string } {
const label = opts.fieldLabel ?? 'URL';
const trimmed = rawValue.trim().replace(/\/+$/, '');
if (trimmed === '') return { ok: true, value: '' };
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return { ok: false, error: `${label} muss eine absolute http(s)-URL sein.` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: `${label}: unzulässiges Schema '${parsed.protocol}'. Nur http(s) erlaubt.` };
}
if (!parsed.hostname) {
return { ok: false, error: `${label}: Host fehlt.` };
}
if (!opts.allowPrivateHosts) {
// Node's URL-Parser lässt eckige Klammern im hostname für IPv6
// (`http://[::1]` → hostname `"[::1]"`). Klammern strippen, sonst
// matcht der Loopback-Pattern `^::1$` nicht.
const hostForCheck = parsed.hostname.replace(/^\[|\]$/g, '');
if (isPrivateOrBlockedHost(hostForCheck)) {
return {
ok: false,
error: `${label}: Host '${hostForCheck}' ist gesperrt (interne, private oder Loopback-Adresse). Bitte öffentlich erreichbare Domain verwenden.`,
};
}
}
return { ok: true, value: trimmed };
}
+6
View File
@@ -67,6 +67,12 @@ services:
LISTEN_ADDR: 0.0.0.0
CORS_ORIGINS: ${CORS_ORIGINS:-}
HTTPS_ENABLED: ${HTTPS_ENABLED:-false}
# SSRF-Schutz: bei Cloud-Deploys auf `true` setzen, sonst kann ein
# eingeloggter Admin via Provider-Test-Connection interne Services
# anpingen (127/10/172.16/192.168, ::1, localhost). On-Prem-Default
# ist `false`, weil Plesk/Dovecot häufig lokal laufen. Cloud-
# Metadata-Endpoints sind UNABHÄNGIG vom Flag immer geblockt.
SSRF_BLOCK_PRIVATE_IPS: ${SSRF_BLOCK_PRIVATE_IPS:-false}
RUN_SEED: ${RUN_SEED:-false}
ports:
- "${OPENCRM_PORT:-3010}:3001"
+4 -1
View File
@@ -15,7 +15,10 @@ DB_PASSWORD=change-this-password
# JWT Authentication
# Generate with: openssl rand -base64 32
JWT_SECRET=change-this-to-a-secure-random-string
JWT_EXPIRES_IN=7d
# Access-Token kurz (XSS-Schutz, Token lebt nur im JS-Memory).
# Refresh-Token lang im httpOnly-Cookie.
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Encryption Key (for portal credentials)
# Generate with: openssl rand -hex 32
+6
View File
@@ -29,6 +29,12 @@ if [ "$RUN_SEED" = "true" ]; then
npx tsx prisma/seed.ts
fi
# Einmal-Bereinigung für Pentest-Reste (Runde 12): XSS-Strings aus
# Customer/User-Feldern entfernen + unbekannte AppSettings löschen.
# Idempotent läuft bei jedem Container-Start ohne Risiko.
echo "Running data cleanup..."
npx tsx prisma/cleanup-xss-and-mass-assignment.ts || echo " (Cleanup übersprungen, nicht-kritisch)"
# Start the application
echo "Starting OpenCRM server..."
exec node dist/index.js
+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:
+1789
View File
File diff suppressed because it is too large Load Diff
+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",
+29 -1
View File
@@ -8,6 +8,7 @@ import Layout from './components/layout/Layout';
import Login from './pages/Login';
import PasswordResetRequest from './pages/PasswordResetRequest';
import PasswordResetConfirm from './pages/PasswordResetConfirm';
import ChangeInitialPassword from './pages/ChangeInitialPassword';
import Dashboard from './pages/Dashboard';
import CustomerList from './pages/customers/CustomerList';
import CustomerDetail from './pages/customers/CustomerDetail';
@@ -31,6 +32,7 @@ import FactoryDefaults from './pages/settings/FactoryDefaults';
import AuditLogs from './pages/settings/AuditLogs';
import EmailLogPage from './pages/settings/EmailLogs';
import Monitoring from './pages/settings/Monitoring';
import RateLimits from './pages/settings/RateLimits';
import GDPRDashboard from './pages/settings/GDPRDashboard';
import UserList from './pages/users/UserList';
import Settings from './pages/Settings';
@@ -49,7 +51,7 @@ import PortalProfile from './pages/portal/PortalProfile';
import PortalMeters from './pages/portal/PortalMeters';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return (
@@ -63,9 +65,31 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <Navigate to="/login" replace />;
}
// Force-Change-Password-Flow: nach Einmalpasswort-Login muss der Kunde
// zwingend ein eigenes Passwort vergeben, bevor er irgendwohin sonst
// navigieren darf.
if (user?.mustChangePassword) {
return <Navigate to="/change-initial-password" replace />;
}
return <>{children}</>;
}
function ChangeInitialPasswordGate() {
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Laden...</div>
</div>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
// Wer nicht im Einmalpasswort-Flow ist, hat hier nichts zu suchen.
if (!user?.mustChangePassword) return <Navigate to="/" replace />;
return <ChangeInitialPassword />;
}
function PortalConsentGate({ children }: { children: React.ReactNode }) {
const { isCustomerPortal } = useAuth();
@@ -153,6 +177,9 @@ function App() {
<Route path="/password-reset/request" element={<PasswordResetRequest />} />
<Route path="/password-reset" element={<PasswordResetConfirm />} />
{/* Einmalpasswort → eigenes Passwort vergeben (eingeloggt, eigene Gate-Logik) */}
<Route path="/change-initial-password" element={<ChangeInitialPasswordGate />} />
<Route
path="/"
element={
@@ -204,6 +231,7 @@ function App() {
<Route path="settings/audit-logs" element={<AuditLogs />} />
<Route path="settings/email-logs" element={<EmailLogPage />} />
<Route path="settings/monitoring" element={<Monitoring />} />
<Route path="settings/rate-limits" element={<RateLimits />} />
<Route path="settings/gdpr" element={<GDPRDashboard />} />
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
@@ -75,7 +75,8 @@ export default function ContractDetailModal({ contractId, isOpen, onClose }: Con
</div>
{/* Anbieter & Tarif */}
{(c.providerName || c.provider?.name || c.tariffName || c.tariff?.name) && (
{(c.providerName || c.provider?.name || c.tariffName || c.tariff?.name
|| c.customerNumberAtProvider || c.contractNumberAtProvider) && (
<Card title="Anbieter & Tarif">
<div className="grid grid-cols-2 gap-4">
{(c.providerName || c.provider?.name) && (
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, History, Clock, Bot, User } from 'lucide-react';
import Modal from '../ui/Modal';
@@ -11,11 +12,57 @@ import type { ContractHistoryEntry } from '../../types';
interface ContractHistorySectionProps {
contractId: number;
canEdit: boolean;
// Map: contractNumber → contractId. Wird genutzt um in title/description
// erwähnte Vertragsnummern als Link auf den jeweiligen Vertrag zu rendern.
// Aufgebaut aus previousContract + followUpContract des aktuellen Vertrags.
knownContracts?: Record<string, number>;
}
// Vertragsnummer-Pattern: 3 Großbuchstaben + Bindestrich + Alphanumerisch
// (siehe backend/src/utils/helpers.ts generateContractNumber).
const CONTRACT_NUMBER_REGEX = /\b([A-Z]{3}-[A-Z0-9]{6,})\b/g;
// Rendert einen Text und ersetzt enthaltene Vertragsnummern durch Links,
// falls sie in der knownContracts-Map auflösbar sind. Nicht aufgelöste Nummern
// bleiben als normaler Text.
function renderTextWithContractLinks(
text: string,
knownContracts?: Record<string, number>,
): React.ReactNode {
if (!knownContracts || Object.keys(knownContracts).length === 0) return text;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
CONTRACT_NUMBER_REGEX.lastIndex = 0;
while ((match = CONTRACT_NUMBER_REGEX.exec(text)) !== null) {
const num = match[1];
const id = knownContracts[num];
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
if (id) {
parts.push(
<Link
key={`${match.index}-${num}`}
to={`/contracts/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline font-mono"
>
{num}
</Link>,
);
} else {
parts.push(num);
}
lastIndex = match.index + num.length;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts.length > 0 ? <>{parts}</> : text;
}
export default function ContractHistorySection({
contractId,
canEdit,
knownContracts,
}: ContractHistorySectionProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
@@ -84,7 +131,7 @@ export default function ContractHistorySection({
})}
</span>
{' - '}
{sortedEntries[0].title}
{renderTextWithContractLinks(sortedEntries[0].title, knownContracts)}
</div>
)}
@@ -99,7 +146,7 @@ export default function ContractHistorySection({
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-800">
{entry.title}
{renderTextWithContractLinks(entry.title, knownContracts)}
</span>
{entry.isAutomatic ? (
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-blue-100 text-blue-700" title="Automatisch erstellt">
@@ -115,7 +162,7 @@ export default function ContractHistorySection({
</div>
{entry.description && (
<p className="text-sm text-gray-600 whitespace-pre-wrap mb-1">
{entry.description}
{renderTextWithContractLinks(entry.description, knownContracts)}
</p>
)}
<div className="flex items-center gap-3 text-xs text-gray-400">
@@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query';
import { X, RefreshCw, User } from 'lucide-react';
import { customerApi } from '../../services/api';
import { CopyableValue } from '../ui/CopyButton';
import Button from '../ui/Button';
interface CustomerInfoModalProps {
customerId: number;
open: boolean;
onClose: () => void;
}
/**
* Schnellansicht der wichtigsten Kundendaten wird aus der
* Vertragsdetail-Seite per Info-Icon neben dem Kundennamen geöffnet.
* Jedes Feld hat einen Copy-Button rechts. Modal-only, schreibt nichts.
*/
export default function CustomerInfoModal({ customerId, open, onClose }: CustomerInfoModalProps) {
const { data, isLoading } = useQuery({
queryKey: ['customer-info-modal', customerId],
queryFn: () => customerApi.getById(customerId),
enabled: open,
staleTime: 30_000,
});
if (!open) return null;
const c = data?.data;
const fullName = c
? [c.salutation, c.firstName, c.lastName].filter(Boolean).join(' ')
: '';
const primaryAddress = c?.addresses?.find((a) => a.isDefault) || c?.addresses?.[0];
const addressString = primaryAddress
? `${primaryAddress.street} ${primaryAddress.houseNumber ?? ''}, ${primaryAddress.postalCode} ${primaryAddress.city}`.trim()
: '';
const formatDate = (iso?: string) => {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
});
} catch {
return iso;
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<User className="w-5 h-5 text-blue-600" />
Kundendaten
{c && (
<span className="text-sm font-normal text-gray-500">
· {c.customerNumber}
</span>
)}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-700 p-1"
aria-label="Schließen"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 overflow-auto flex-1">
{isLoading && (
<div className="text-gray-500 text-sm flex items-center gap-2 py-6">
<RefreshCw className="w-4 h-4 animate-spin" />
Lade Kundendaten
</div>
)}
{c && (
<dl className="space-y-3">
{c.type === 'BUSINESS' && c.companyName && (
<CopyableValue label="Firma" value={c.companyName} />
)}
{fullName && <CopyableValue label="Name" value={fullName} />}
{c.birthDate && (
<CopyableValue label="Geburtsdatum" value={formatDate(c.birthDate)} />
)}
{c.birthPlace && <CopyableValue label="Geburtsort" value={c.birthPlace} />}
{c.foundingDate && (
<CopyableValue label="Gründungsdatum" value={formatDate(c.foundingDate)} />
)}
{addressString && (
<CopyableValue label="Adresse" value={addressString} />
)}
{c.phone && <CopyableValue label="Telefon" value={c.phone} />}
{c.mobile && <CopyableValue label="Mobil" value={c.mobile} />}
{c.email && <CopyableValue label="E-Mail" value={c.email} />}
{c.portalEmail && c.portalEmail !== c.email && (
<CopyableValue label="Portal-E-Mail" value={c.portalEmail} />
)}
{c.taxNumber && (
<CopyableValue label="Steuernummer" value={c.taxNumber} />
)}
{c.commercialRegisterNumber && (
<CopyableValue
label="Handelsregisternummer"
value={c.commercialRegisterNumber}
/>
)}
</dl>
)}
</div>
<div className="px-5 py-3 border-t border-gray-200 flex justify-end">
<Button variant="secondary" onClick={onClose}>
Schließen
</Button>
</div>
</div>
</div>
);
}
@@ -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';
@@ -9,7 +10,7 @@ import Select from '../ui/Select';
import Badge from '../ui/Badge';
import { invoiceApi } from '../../services/api';
import type { Invoice, InvoiceType } from '../../types';
import { fileUrl } from '../../utils/fileUrl';
import { fileUrl, viewUrl } from '../../utils/fileUrl';
const invoiceTypeLabels: Record<InvoiceType, string> = {
INTERIM: 'Zwischenrechnung',
@@ -121,7 +122,7 @@ export default function InvoicesSection({
{invoice.documentPath && (
<div className="flex items-center gap-2">
<a
href={fileUrl(invoice.documentPath)}
href={viewUrl(invoice.documentPath)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
@@ -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,15 +388,31 @@ function InvoiceModal({
onChange={handleFileSelect}
className="hidden"
/>
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
>
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
</Button>
<div className="flex gap-2 flex-wrap">
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
>
{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,15 +312,39 @@ export default function ComposeEmailModal({
className="hidden"
/>
{/* Anhang hinzufügen Button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Paperclip className="w-4 h-4 mr-2" />
Datei anhängen
</button>
{/* Anhang-/Daten-Buttons */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<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>
);
}
@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react';
import { Mail, MailOpen, Star, Paperclip, Plus, X, ChevronRight, Inbox, Send, RefreshCw, Trash2 } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Mail, MailOpen, Star, Paperclip, Plus, X, ChevronRight, Inbox, Send, RefreshCw, Trash2, ExternalLink } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
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';
@@ -48,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 || {
@@ -77,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,
};
@@ -267,10 +275,40 @@ export default function ContractEmailsSection({
return email.fromName || email.fromAddress;
};
// Shared Card-Titel mit Quicklinks zur Kundenakte wird sowohl im
// Normal-Zweig als auch im "keine Mailbox vorhanden"-Zweig gezeigt,
// damit man auch ohne eingerichtetes Postfach direkt zu den
// Stressfrei-Wechseln-Adressen springen kann.
const cardTitle = (
<div className="flex items-center gap-3">
<span>E-Mails</span>
<Link
to={`/customers/${customerId}?tab=emails`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline font-normal"
title="Komplettes E-Mail-Postfach des Kunden in neuem Tab öffnen"
>
<ExternalLink className="w-3 h-3" />
Postfach öffnen
</Link>
<Link
to={`/customers/${customerId}?tab=stressfrei`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline font-normal"
title="Stressfrei-Wechseln-Adressen des Kunden in neuem Tab öffnen"
>
<ExternalLink className="w-3 h-3" />
Stressfrei wechseln Adressen
</Link>
</div>
);
// Keine Mailbox-Konten vorhanden
if (!accountsLoading && accounts.length === 0) {
return (
<Card title="E-Mails">
<Card title={cardTitle}>
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<Mail className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">Keine E-Mail-Konten vorhanden</p>
@@ -284,11 +322,7 @@ export default function ContractEmailsSection({
return (
<Card
title={
<div className="flex items-center gap-4">
<span>E-Mails</span>
</div>
}
title={cardTitle}
actions={
<div className="flex items-center gap-2">
{selectedFolder !== 'TRASH' && (
@@ -331,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>
)}
@@ -402,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>
@@ -443,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}

Some files were not shown because too many files have changed in this diff Show More