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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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, "javascript:",
"<script>" und "<script>" 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>