Commit Graph

259 Commits

Author SHA1 Message Date
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