POST /api/audit-logs/verify meldete ~73% der Einträge als
"manipuliert" – kein echtes Tampering, sondern ein Serialisierungs-
Bug in generateHash: resourceId war beim Schreiben oft undefined
(Middleware-Einträge ohne Route-ID). JSON.stringify lässt einen
undefined-Wert weg → Hash ohne resourceId-Key. In der DB steht der
Wert aber als NULL; verifyIntegrity/rehashAll lasen null zurück und
JSON.stringify({resourceId:null}) schrieb ihn rein → anderer Hash
→ Fehlalarm für jede Zeile mit leerem resourceId. Die vom Pentester
gefundene durationMs-Korrelation war nur ein Proxy (Middleware-
Einträge haben durationMs UND oft kein resourceId).
Fix: generateHash lässt nullish resourceId weg – reproduziert exakt
das historische Schreibverhalten (undefined → Key weg). Kein Caller
übergibt je null explizit (verifiziert), daher matchen alle
Bestands-Hashes ohne Rehash; Feldreihenfolge unverändert.
Empirisch gegen echte DB bewiesen: aktuelle Hash-Ära (id>4141)
482/482 valide (vorher wurden alle 289 leeren-resourceId-Zeilen
falsch geflaggt).
Separat/vorbestehend (NICHT dieser Fix): Einträge vor Commit
fd55742 ("complete new audit system") nutzen ein altes Hash-Schema
und brauchen einmalig POST /api/audit-logs/rehash zum Re-Baselinen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Beim Suchen eines verify-integrity-Endpoints stiess der Pentester
auf einen 500er: GET /api/audit-logs/verify matcht GET /:id (das
echte Verify ist POST /verify), parseInt("verify") = NaN → Prisma
findUnique({ where:{ id: NaN }}) wirft. Gleiche 400-statt-500-
Klasse wie R64.1/R104.1.
Fix: Number.isNaN-Guard in getAuditLogById, getAuditLogsByCustomer
und updateRetentionPolicy → sauberer 400 „Ungültige ID".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der Retest deckte auf, dass abgewehrte Cross-Customer-Zugriffe
(canAccess*-403) nur im SecurityEvent-Monitoring-Stream landeten
(ACCESS_DENIED, /api/monitoring/events) – nicht im AuditLog, wo
der Pentester suchte. Der Monitoring-Stream ist zudem löschbar
(DELETE /api/monitoring/events) und nicht hash-verkettet.
emitAccessDenied schreibt jetzt zusätzlich einen tamper-evidenten
AuditLog-Eintrag (action READ, resourceType 'AccessDenied',
Sensitivity HIGH), der ein Clearen des Monitoring-Streams
überlebt und über die Hash-Kette manipulationssicher ist. Gilt
für alle canAccess*-403 (Contract + Customer, inkl. Vollmacht-
fehlt-Fall). Kein Enum-/Schema-Change: READ + distinktiver
resourceType, filterbar via searchAuditLogs.
Korrigiert damit auch meine frühere ungenaue Aussage „landet im
Audit" – vorher war das die falsche Tabelle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /api/contracts?tree=true&customerId=<fremd> gab Portal-Usern
den vollständigen Vertragsbaum beliebiger Fremdkunden zurück
(Name, Kundennummer, Vertragsnummern, Tarife – HTTP 200). Der
tree=true-Zweig returnte früh, bevor die Portal-User-customerIds-
Filterung griff, die für die flache Liste im selben Handler läuft.
Vorbestehender Bug; der includeDeactivated-Toggle machte ihn nur
sichtbarer (auch archivierte Fremdverträge kamen mit). Live vom
Pentester bestätigt, Gegentest ohne den Param = identisches Leck.
Fix: canAccessCustomer(req, res, customerId) vor dem frühen
Return. Prüft eigene ID + vertretene MIT Live-Vollmacht, sendet
selbst die 403. Staff passiert unverändert. Gleiches Muster wie
Pentest 56.3 bei update/delete.
Flacher Listenpfad war nie betroffen: getAllContracts bevorzugt
das serverseitig gesetzte customerIds-Scope gegenüber dem rohen
customerId-Query-Param.
Docs: SECURITY-HARDENING.md § Runde 120 + docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der Vertragsbaum beim Kunden blendete DEACTIVATED-Verträge
komplett aus. Da der aktuellste Vertrag die Baumwurzel ist und
Vorgänger als Children hängen, verschwand eine ganze Kette aus
der Ansicht, sobald die Wurzel deaktiviert wurde – so „verschwand"
ein Vertrag scheinbar, als ein aktiver Folgevertrag gelöscht und
der Vorgänger vorher deaktiviert worden war.
getContractTreeForCustomer bekommt ein optionales
includeDeactivated-Flag (Default false = bisheriges Verhalten),
durchgereicht per Query-Param includeDeactivated=true. Toggle-
Button (Eye/EyeOff) im Kunden-Vertragstab; showDeactivated ist
Teil des Query-Keys → frischer Fetch beim Umschalten. Deaktivierte
behalten ihr graues DEACTIVATED-Badge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Zwei zusammenspielende Bugs:
1) Backend: deleteContract-Reihenfolge scheiterte am UNIQUE-
Constraint auf Contract.previousContractId. Beim Middle-Delete
(A → B → C, B löschen) hielt B im UPDATE-Moment noch selbst
previousContractId=A – der Versuch, C ebenfalls auf A
umzubiegen, warf MariaDB Duplicate-Entry, das UPDATE brach ab.
Fix: Transaction, umgedrehte Reihenfolge – erst B löschen
(DB-Cascade ON DELETE SET NULL räumt C.previousContractId ab,
gibt A-Slot frei), dann C sauber auf A umbiegen.
2) Frontend: deleteMutation invalidierte weder Contract-Listen
noch Kunden-Vertragsbaum und hatte keinen onError-Handler.
Nach dem Delete wurde nach /contracts navigiert, dort zeigte
der stale Cache noch den gelöschten Vertrag – Eindruck: „ganze
Historie weg". Bei Bug 1 fehlgeschlagen brach das schweigend
ab.
Fix: queryClient.invalidateQueries für ['contracts'],
['contract-tree', customerId], ['customer', customerId] nach
Erfolg. onError-Toast bei Fehler.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Auf Wunsch des Pentesters (R117-Nachtrag). Das Umschalten des
portalCredentialsNotRequired-Flags emittiert jetzt zusätzlich zum
generischen Contract-Update-Diff ein dediziertes UPDATE-Event
unter resourceType 'ContractPassword' – landet damit in derselben
CRITICAL-Sensitivity-Reihe wie Klartext-Password-Reads.
Motivation: das Setzen des Flags räumt server-seitig
portalUsername, portalPasswordEncrypted und stressfreiEmailId auf
NULL. Diese Löschung soll unabhängig vom generischen Diff sofort
sichtbar sein. Rücknahme wird ebenfalls geloggt.
Details enthält alte + neue Flag-Werte plus Bool-Marker, welche
Anmeldedaten vor dem Opt-out belegt waren – keine Klartext-
Leckage im Log.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dritter Radio-Button in der Zugangsdaten-Card des Vertragsformulars.
Wenn gesetzt, unterdrückt das Cockpit die Warnung „Portal-Zugangs-
daten fehlen" für diesen Vertrag – für Anbieter ohne Portal oder
Kunden, die bewusst keine Zugangsdaten pflegen. Verstopft das
Cockpit sonst dauerhaft.
Neues Feld Contract.portalCredentialsNotRequired (Boolean, default
false) + idempotente Migration (ADD COLUMN IF NOT EXISTS). Bestand
bleibt auf false, Warnung greift wie bisher.
Beim Umschalten auf Opt-out werden portalUsername,
portalPasswordEncrypted und stressfreiEmailId server-seitig
explizit auf NULL gesetzt – Datenhygiene, damit keine verwaisten
Anmeldedaten in der DB stehen bleiben.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: Ein DEACTIVATED-Vertrag (manuell archiviert) zeigte im
Cockpit weiter "Vertragsende: Vertrag seit 79 Tagen abgelaufen!"
– sinnlos, weil der Mitarbeiter den Vertrag ja aktiv beendet
hatte.
Fix: skipDeadlines-Guard, der bisher nur ONGOING abfing, greift
jetzt auch bei DEACTIVATED und CANCELLED. Beide sind terminale
Status – laufende Fristen ergeben da semantisch keinen Sinn.
Bewusst weiterhin AKTIV:
- Block 13a (Schlussrechnung fehlt): feuert wegen dieser Status,
nicht trotzdem.
- Block 8 (DSGVO/Consent): läuft pro Kunde, nicht pro Vertrag.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pentester meldete 403 auf POST/PUT/DELETE /api/contract-categories/*
für Admin trotz vorhandener contract-categories:*-Perms. Vermutete
Regression aus R110, war aber schon seit dem allerersten Commit
so: Route verlangte developer:access, während der Permission-Seed
den Admins contract-categories:* gibt.
Fix: Route auf contract-categories:create/update/delete umgestellt,
konsistent mit den sechs Peer-Katalog-Endpunkten (platforms/
tariffs/cancellation-periods/… nutzen alle ressourcen-spezifische
Perms). Kommentar aktualisiert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MEDIUM: PUT /api/stressfrei-emails/:id und 6 weitere Update-
Endpunkte (platform, tariff, contractCategory, cancellationPeriod,
contractDuration, email-providers) reichten req.body ungefiltert
an Prisma. Gleiche Bug-Klasse wie das gefixte M1-Finding, sieben
Stellen mehr. Nachgewiesen via provisionError-Feld ausserhalb des
TS-Types.
Fix: sieben Whitelists + pickXxxUpdate()-Helper in sanitize.ts,
in den jeweiligen Controllern eingehängt. Reuse der bewährten
pick()-Infrastruktur (Customer/User seit Runde 7).
EmailProvider bewusst OHNE stripHtmlFromStrings, weil Passwörter
und API-Keys legitim Sonderzeichen enthalten dürfen.
Doku: SECURITY-HARDENING.md § Runde 110 + docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der User sah beim Postfach-Sync stumpf "Command failed" – realer
Grund war ein abgelaufenes Postfach-Passwort. Der Bug betraf
Anhang-Download UND Sync und war der gleiche wie 2 Commits zuvor:
imapflow wirft `new Error('Command failed')` und legt Details in
`.responseText`/`.responseStatus` ab, wir haben sie nirgends
gelesen.
Fix: humanizeImapError() als zentraler Helper in imapService:
- Extrahiert responseText/responseStatus aus imapflow-Errors
- Erkennt Auth-Fehler ("authentication failed", "invalid
credentials", NO+auth) und liefert klare Meldung mit
Handlungsanweisung: "Passwort stimmt nicht mehr, bitte
Zugangsdaten synchronisieren".
- Deckt zusätzlich Netzwerk/TLS/Timeout/UID-Stale ab.
Angewendet auf:
- fetchAttachmentInner (Anhang-Fetch)
- downloadAttachment-Controller (Response an UI)
- syncEmailsForAccount (Sync-Ergebnis + Toast)
Damit sieht der User im Toast künftig statt "Command failed" die
tatsächliche Ursache – gleicher Mechanismus für Sync und
Anhang-Download.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: Anhang-Download schlug mit "Fehler beim Herunterladen des
Anhangs: Command failed" fehl – ohne jeden Kontext, warum.
Ursache: imapflow wirft bei jedem IMAP-NO/BAD-Response nur
`new Error('Command failed')` und legt den echten Server-Grund
in `.responseText` / `.responseStatus` ab. Unser Code las nur
`.message` und verlor damit alle Information.
Fix:
- fetchAttachmentInner extrahiert responseText/responseStatus
aus dem imapflow-Error und packt sie in die geworfene Meldung.
- downloadAttachment-Catch macht das gleiche, damit auch andere
IMAP-Fehler (mailboxOpen, connect) mit sinnvollem Text
durchkommen.
- Zusätzlicher Friendly-Mapping-Fall für "no longer exist" /
"no such message" → klare Meldung, dass die E-Mail-Liste
neu synchronisiert werden sollte.
Damit sieht der User im UI jetzt z.B. "NO Some of the requested
messages no longer exist" statt "Command failed" und weiß, dass
ein Resync hilft.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neues optionales Freitext-Feld `description` (TEXT NULL) auf
BankCard – z.B. "Geschäftskonto", "gemeinsames Konto mit Partner".
Migration mit IF NOT EXISTS.
Backend service create/update nimmt description entgegen.
Frontend:
- BankCard-Type um description ergänzt.
- BankCardModal: neue Textarea zwischen Ablaufdatum und Aktiv-
Checkbox mit Placeholder-Hilfetext.
- Bank-Kartenübersicht zeigt die Beschreibung kursiv unter den
bestehenden Feldern, falls gesetzt (whitespace-pre-line für
mehrzeilige Notizen).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
INFO-Finding: GET/PUT /api/customers/:id/salutation-preference
warfen bei nicht-existierendem Customer 500 statt 404.
canAccessCustomer prüft für Staff-User nur den Portal-Flag, kein
Existenz-Check. Der Service warf `new Error('Kunde nicht
gefunden')`, was der Controller-Catch generisch auf 500 mappte.
set/clear kamen als Prisma-P2003 (FK-Constraint) durch.
Fix:
- Neuer Helper assertCustomerExists() im customer.service, wirft
ApiError(404, ...). Wird von get/set/clearSalutationPreference
aufgerufen.
- Die drei Controller-Catches respektieren jetzt
ApiError.statusCode → 404 kommt sauber durch, alle anderen
Fehler bleiben bei 500.
Doku: docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
INFO-Finding: die interne CRM-Vertragsnummer war im Update-Path
nicht durch validateContractIdentifier abgedeckt. <script>… →
stripHtml → leerer String → Vertragsnummer silent überschrieben
(R87.1-Pattern auf einem sechsten Feld).
Fix: contractNumber zu CONTRACT_IDENTIFIER_FIELDS hinzugefügt.
Bestehende Whitelist (^[A-Za-z0-9_\-/. ]{0,100}$) und Raw-Input-
Validierung greifen automatisch. Legitime generierte Nummern
(GAS-MPVJ4P10FE8) bleiben OK, <script>/Overflow/CRLF/Tags → 400.
Kein Migration- oder Frontend-Change – der Fix nutzt den
bestehenden R86-Mechanismus.
Doku in SECURITY-HARDENING.md § Runde 102 und docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neue Tabelle UserCustomerSalutation (PK userId+customerId,
preference 'DU'|'SIE'). Fehlender Eintrag → Fallback auf
Customer.useInformalAddress (Kunden-Default).
Backend:
- customerService: get/set/clearSalutationPreference mit Fallback-
Logik. Response enthält immer effektive Präferenz + `source`
('user' | 'customer-default'), damit die UI den Standard-Text
anzeigen kann.
- customerController: 3 Endpunkte GET/PUT/DELETE
/:customerId/salutation-preference. userId aus dem JWT, canAccessCustomer
greift.
Frontend:
- customerApi: 3 neue Methoden.
- CustomerDetail: neues Feld "Anrede für mich" mit Du/Sie-Toggle
und Zurücksetzen-Link. Wird nur Mitarbeitern angezeigt, nicht
Portal-Usern.
- Bestehendes Feld "Anrede per" bekommt Hinweis "Standard für alle
Mitarbeiter", damit die Semantik der beiden Felder klar ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Enum-Wert ONGOING neu, MariaDB-Migration (ans Ende gehängt, damit
keine Rows umgeschrieben werden). Prisma Client neu generiert.
Cockpit-Logik: ONGOING wird in die Query mit reingeholt (damit
DSGVO-/Consent-Warnungen weiter greifen), aber alle Fristen-
Blöcke pro Vertrag geskippt:
- Kündigungsfrist
- Vertragsende
- Zwischenrechnung-Frist
Kein Skip für Daten-Qualität (fehlende Adresse, Bank, Portal-
Zugang etc.) – die sind auch für unbefristete Verträge relevant.
Frontend: statusLabels/statusVariants/statusDescriptions in
ContractDetail, ContractList, CustomerDetail und
ContractDetailModal um ONGOING ergänzt. Status-Select im
ContractForm bekommt die neue Option zwischen ACTIVE und
CANCELLED.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Suche und Add haben bisher nur Kunden mit portalEnabled=true zugelassen –
das machte das Feature quasi unnutzbar, weil die meisten Kunden kein
Portal aktiviert haben. Der Zugriff ist ohnehin erst dann effektiv,
wenn der Vertreter ein Portal-Konto bekommt.
- searchCustomersForRepresentative: portalEnabled-Filter raus, dafür
Feld im select mitgeliefert.
- addRepresentative: Portal-Pflicht-Check raus.
- getRepresentedByList: portalEnabled im rep-Include, damit die UI
auch für schon hinterlegte Vertreter das Badge zeigen kann.
- CustomerDetail: gelbes "Portal inaktiv"-Badge in Suchergebnissen
und in der Vertreter-Liste. Hinweistext geändert.
- CustomerSummary-Type: portalEnabled? ergänzt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).