presseportale/dev/archive/optimize/optimize-14-07-2026.md
Kevin Adametz 6a4c4d8837 docs: dev-Doku für den Live-Betrieb neu sortiert — Archiv + Betrieb + Roadmap
Das System ist seit 20.07.2026 live; die dev-Doku spiegelt jetzt den
Betriebszustand statt der Release-Vorbereitung:

- dev/betrieb/: Deploy-Runbook (neu: §10 Betrieb nach Go-Live mit
  SFTP-Deploy-Checkliste inkl. queue:restart-Pflicht und den
  Betriebs-Erkenntnissen der Go-Live-Woche), Legacy-API-Parität, Umami.
- dev/roadmap.md: offene Nachlauf-Punkte (Go-Live-Mails, Merge nach main,
  Stripe-Test-Artefakte, Key-Rotation, Alt-System-Abschaltung) und
  Ideen-Backlog.
- dev/archive/: abgeschlossene Phasen — Migration-2026-Planung,
  Cutover-Runbook (mit Erledigt-Vermerk), Release-Plan/QA-Checkliste,
  Optimierungsphasen, Frontend-Konzepte + Responsive-Audits vom 16.07.,
  Altsystem-Reste.
- dev/README.md als Wegweiser; gitignore-Pfad der SQL-Dumps mitgezogen
  (dev/archive/migration-2026/sql/ bleibt untracked, ebenso dev/migration/
  und dev/api/).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:52:38 +00:00

519 lines
54 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Security- & Code-Audit Pressekonto — 14.07.2026
Vollständiger Audit des Presseportals (Hub `pressekonto` + zwei Spokes `presseecho`/`businessportal24` + Admin- und Kundenbereich).
Stack: Laravel 12.57, PHP 8.4, Livewire 4 / Volt 1 / Flux Pro 2, Fortify 1, Sanctum 4, Cashier 16, Spatie Permission 6, mews/purifier 3.
Jeder Befund ist gegen den tatsächlichen Code verifiziert; Datei:Zeile ist angegeben. Unsichere Einschätzungen sind als **„Zu verifizieren"** markiert.
---
## 0. Architektur-Zusammenfassung (Bestandsaufnahme)
**Domain-/Theme-Modell (sehr sauber gelöst).** `ThemeServiceProvider` löst den Host einmalig zu einem Theme auf und delegiert Asset-/Vite-/URL-Verdrahtung an `App\Support\DomainAssetContext`. Beide öffentlichen Spokes teilen sich `view_prefix = 'web'` und `assets_dir = 'build/web'` (`config/domains.php`) — **ein** View-Baum, **ein** JS/CSS-Build. Die Themes unterscheiden sich nur über CSS-Token-Werte (`resources/css/web/theme-*.css`). Die vermutete „doppelte Frontend"-Problematik ist damit weitgehend bereits gelöst (Details in Abschnitt 4).
**Routing.** `bootstrap/app.php` lädt `routes/domains.php` als Web-Einstieg. Der Portal-Host (`pressekonto`) bindet `auth.php` + `admin.php` + `customer.php`; `web.php` (öffentliche Spoke-Inhalte, host-agnostisch, mit `/{edition}`-Präfix de/en) und `legal.php` werden für alle Domains geladen. API: `routes/api.php` (Sanctum, `Api\V1\*`) + `routes/legacy-api.php` (Bestands-Integrationen `/service/*`).
**Trennung User/Admin/Customer.** Zwei Gates: `access-admin` (Rollen admin **und** editor, oder super_admin), `access-customer` (admin/editor/customer). Admin-Routen hängen an `EnsureUserIsAdmin` + `BlockAdminDuringImpersonation`; Kundenbereich `/admin/me/*` an `EnsureUserIsCustomer`. Beide sind als `addPersistentMiddleware` registriert, greifen also auch auf `/livewire/update`-Folge-Requests — die **Panel-Trennung ist dicht**. Feingranulare Permissions (`users:manage`, `roles:manage`, `press-releases:publish` …) existieren über Spatie, werden aber **innerhalb** des Admin-Panels nicht überall erzwungen (siehe K1/H2).
**Domänenmodell.** 50+ Models. Kern: `PressRelease` (Status-Maschine Draft→Review→Published/Depublished, KI-Klassifizierung/Scoring via Queue-Jobs), `Company`/`Contact` (CRM + öffentliche Newsrooms), Billing (`CreditWallet`/`CreditTransaction`/`CreditTopup`, `Invoice`, Cashier-Subscriptions, Stripe-Webhooks), Legacy-Import (`Legacy*`-Models + Legacy-API-Parität).
**Testbestand.** ~176 Testdateien über Auth, Billing, API, Publishing, IDOR/Ownership — überdurchschnittlich breit.
**Unklar / nicht erschöpfend gelesen:** jede einzelne Admin-Livewire-Komponente wurde teils stichprobenartig geprüft; der vollständige `app/Services/*`-Baum (gut faktorisiert). Als „Zu verifizieren" gekennzeichnete Punkte vor einem Fix bestätigen.
---
## 1. Executive Summary
Der Gesamtzustand ist **gut bis sehr gut**: durchdachtes Theme-/Domain-System, dünne Controller mit Business-Logik in Services, kundenseitig durchweg sauberer IDOR-Schutz über Ownership-Scopes (`accessibleBy`) und Policies, HTMLPurifier für fremden PM-Content (doppelt: beim Speichern und beim Rendern), atomares Wallet-/Rechnungsnummern-Handling, breite Testabdeckung und eine performance-bewusste Read-Schicht.
Es gibt jedoch **zwei kritische** und mehrere hohe Befunde, die vor einem Produktivbetrieb behoben werden müssen. Das schwerwiegendste ist eine **Rechteausweitung**: Die Benutzer- und Rollenverwaltung ist nur durch das grobe `access-admin`-Gate geschützt, das die Rolle `editor` einschließt — ein `editor` kann über die Verwaltungsmasken `is_super_admin = true` setzen, sich die Rolle `admin` zuweisen oder einer Rolle beliebige Permissions geben und damit die volle Kontrolle übernehmen, obwohl `users:manage`/`roles:manage` ihm bewusst vorenthalten sind. Dasselbe Gate lässt einen `editor` auch alle destruktiven Admin-CRUD-Aktionen (Firmen/PMs löschen, publizieren) ausführen. Zweitens liegen **echte Produktions-Secrets im Klartext in `.env`** (Live-OpenAI-Key, Google-OAuth-Secret, Mail-Passwort); die Datei ist zwar korrekt gitignored, die Secrets sind aber zu rotieren.
Hoch eingestuft sind zudem eine **Impersonation ohne Rangprüfung** (ein Admin kann einen Super-Admin übernehmen und in dessen Namen ein persistentes API-Token erzeugen), eine **Doppel-Gutschrift bei gleichzeitigem Stripe-Webhook-Retry** (echtes Geld), ein **`javascript:`-Link aus dem anonymen Kontaktformular**, der im Admin-Backend ausgeführt wird, das ungeschützte Vertrauen in alle Proxies inkl. `X-Forwarded-Host` (Host-Header-Injection) sowie veraltete Abhängigkeiten mit HIGH-CVEs. Die Performance- und Duplikations-Findings sind inkrementelle Härtung.
---
## 2. Befunde (sortiert nach Schweregrad)
| # | Schweregrad | Kategorie | Datei:Zeile | Beschreibung | Empfohlener Fix |
|---|---|---|---|---|---|
| K1 | **Kritisch** ✅ behoben | Broken Access Control / Mass Assignment | `resources/views/livewire/admin/users/edit.blade.php:422,425` · `create.blade.php:127,131` · `admin/roles/edit.blade.php:67` · `roles/create.blade.php` | `editor` (via `access-admin`-Gate) kann auf User-/Rollen-Masken `is_super_admin=true` setzen, sich Rolle `admin` zuweisen oder einer Rolle Permissions geben — keine `users:manage`/`roles:manage`-Prüfung. Volle Eskalation. | In `mount()` + jeder Schreibmethode `abort_unless(...->can('users:manage'/'roles:manage'),403)`; `is_super_admin`/Admin-Rolle nur für Super-Admins. **→ Umgesetzt, siehe Abschnitt 7.** |
| K2 | **Kritisch** | Secrets | `.env` (OPENAI_API_KEY, GOOGLE_CLIENT_SECRET, MAIL_PASSWORD) | Echte Live-Secrets im Klartext (`sk-proj-…`, `GOCSPX-…`). `.env` ist gitignored (gut), Secrets aber als kompromittiert zu behandeln. | Secrets **rotieren**; auskommentierte Live-Credentials entfernen; Secret-Manager/Deploy-Env statt Datei. |
| H1 | **Hoch** ✅ behoben | Autorisierung / Impersonation | `app/Actions/Admin/UserImpersonation.php:26-29` | `canBeImpersonated()` erlaubt jedes Ziel mit `canAccessCustomer()` — inkl. Admins/Super-Admins. Impersonierter Kundenbereich erlaubt **API-Token-Erstellung** → persistenter Zugriff nach Verlassen. | Rangprüfung: Ziel darf nicht `canAccessAdmin()`/`is_super_admin` sein; ggf. Token-Erstellung während Impersonation sperren. |
| H2 | **Hoch** ✅ behoben | Broken Access Control (systemisch) | `admin/companies/edit.blade.php:187` · `press-releases/index.blade.php:220,244,259` · `edit.blade.php:337` · `categories/edit.blade.php:134` u. a. | Destruktive/publizierende Admin-CRUD-Aktionen sind nur `access-admin`-gated, prüfen keine spezifische Permission. `editor` kann Firmen/PMs löschen, publizieren (`press-releases:publish` wird nicht aufgerufen). **Zu verifizieren**, ob `editor` gewollt Vollzugriff hat. | Zielrechte je Aktion definieren, per `authorize('...')`/`can:`-Route erzwingen; Publish konsistent gegen `press-releases:publish`. |
| H3 | **Hoch** ✅ behoben | XSS (Stored, javascript-URI) | `app/Http/Controllers/Web/ContactController.php:40` → `resources/views/livewire/admin/contact-messages/show.blade.php:166` | `page_url` aus anonymem Kontaktformular nur `string` validiert (keine `url`-Regel), im Backend als `<a href="{{ $message->page_url }}">` ausgegeben. `javascript:`-URL läuft beim Admin-Klick im Admin-Kontext. | `'page_url' => ['nullable','url:http,https','max:500']`; defensiv beim Rendern nur http/https-Links klickbar machen. |
| H4 | **Hoch** ✅ behoben | Race Condition / Payments | `app/Services/Billing/CreditTopupService.php:70-91` | Idempotenz-Guard (`credited_at !== null`) liegt **außerhalb** der Transaktion, kein Row-Lock, keine Unique auf `credit_transactions.reference`. Zwei gleichzeitige Webhook-Zustellungen → **doppelte Gutschrift**. | Guard in Transaktion + `lockForUpdate()`; oder Unique-Index auf `(reference_type,reference_id,type)`. |
| H5 | **Hoch** ✅ behoben | Host-Header-Injection | `bootstrap/app.php:79-85` | `trustProxies(at:'*')` inkl. `X-Forwarded-Host`, **keine** `trustHosts`-Whitelist. Client bestimmt Host für URL-Generierung → vergiftbare Reset-/Verify-/signierte Links, Cache-Poisoning. | `->trustHosts(at:[...Domains])`; Proxy-Vertrauen auf bekanntes Subnetz statt `*`. |
| H6 | **Hoch** ✅ behoben | CVEs / veraltete Packages | `composer.lock` | `composer audit`: 19 Advisories, u. a. **HIGH** `symfony/http-kernel` (HEAD-Bypass CVE-2026-45075), `symfony/mime` (E-Mail-Injection CVE-2026-45067), `laravel/framework <12.60` (CRLF im email-Rule). Projekt ist 12.57 → betroffen. | `composer update` auf gepatchte Minor-Versionen; danach `composer audit` erneut. |
| H7 | **Hoch** ✅ behoben | Performance / Caching | `app/Support/CategoryNavigation.php:35-53` | Navigations-Chrome (Header/Footer/Branchenindex/Suche) rechnet pro Request ≥2 uncached Count-Aggregate über das **gesamte** Archiv — auf **jeder** Web-Seite. | `Cache::remember()` keyed `portal.language.limit`, ~300s (analog `TickerData`). |
| M1 | Mittel ✅ behoben | Authentifizierung / 2FA | `app/Http/Controllers/Auth/MagicLinkConsumeController.php:53` | Magic-Link loggt direkt ein, umgeht aktiviertes 2FA (regulärer Login leitet korrekt in die Challenge). | Bei `hasEnabledTwoFactorAuthentication()` auch nach Magic-Link in die 2FA-Challenge. |
| M2 | Mittel ✅ behoben | Race Condition / Logic | `app/Services/PressRelease/PressReleaseService.php:243-277` | `consumePublishSlot()` liest Quota ungelockt, `publish()` nicht in Transaktion → Quota-Überkonsum bei gleichzeitigem Publish (Scheduler + Admin); Slot-Verbrauch ohne Publish bei Teilfehler. | `publish()` in `DB::transaction` + `lockForUpdate` auf User-Zeile vor Read-Modify-Write. |
| M3 | Mittel ✅ behoben | Race Condition | `app/Models/Concerns/HasUniqueSlug.php:39-50` · `Api/V1/PressReleaseController.php:250-269` | Slug-Check-then-insert ohne Lock. Unique-Index verhindert Duplikat, aber zweite gleichzeitige Einreichung → unbehandelte `QueryException` (HTTP 500) statt `-2`-Suffix. | Unique-Violation beim `save()` fangen + neu generieren/retry. |
| M4 | Mittel ✅ behoben | File Upload / Zugriff | `app/Services/PressRelease/PressReleaseAttachmentStorage.php:17-19` · `app/Services/Image/ImageService.php:85` | Medien von **Entwürfen/abgelehnten/Embargo-PMs** liegen auf der `public`-Disk → ohne Auth per URL abrufbar (UUID mildert, verhindert Leak via Referer/Logs nicht). | Vertrauliche Stadien auf `local`-Disk + kontrollierte Auslieferungsroute; Bestandsmedien per Deploy-Kommando migrieren. |
| M5 | Mittel ✅ behoben | File Upload (Defense-in-depth) | `app/Services/PressRelease/PressReleaseAttachmentStorage.php:96-107` | Storage-Schicht prüft nur (Client-)Extension; `ALLOWED_MIMES` (Z. 28-39) ungenutzt. Vorgelagerte Livewire-`mimes:`-Regel mildert; API-Pfad könnte Prüfung umgehen. | In `assertValidUpload()` zusätzlich `in_array($upload->getMimeType(), self::ALLOWED_MIMES, true)`. |
| M6 | Mittel ✅ behoben | XSS (Defense-in-depth) | `app/Http/Controllers/Api/V1/PressReleaseController.php:47,105` | API speichert `text` **ungefiltert** (Livewire-Pfade sanitizen vor Persistenz). Ausgabe fängt es aktuell via `renderedText()` ab; jeder künftige Roh-Sink (RSS/Export/Mail) → Stored-XSS. | Vor `create()`/`fill()`: `app(PressReleaseHtmlSanitizer::class)->clean($validated['text'])`. |
| M7 | Mittel ✅ behoben | Logic / Datenintegrität | `app/Http/Controllers/Api/V1/NewsletterSubscriptionController.php:17-33` | `updateOrCreate` auf `(portal,email)` setzt immer `is_confirmed=false` + überschreibt `user_id`/`ip`. Bestätigter Abonnent wird still ent-bestätigt; fremder API-User übernimmt Ownership. | Bereits bestätigte Datensätze nicht zurücksetzen; Ownership-Rebind unterbinden. |
| M8 | Mittel ✅ behoben | Mass Assignment (latent) | `app/Models/User.php:34-53` | `is_super_admin`/`is_active` im `$fillable`. Aktuell über K1 ausnutzbar, aber Landmine für jedes künftige `update($request…)`. | `is_super_admin` aus `$fillable` entfernt; nur per autorisiertem `forceFill`. **→ Abschnitt 7.** |
| M9 | Mittel | Session-Cookie | `.env` / `config/session.php:172` | `SESSION_SECURE_COOKIE` ungesetzt → kein `Secure`-Flag trotz durchgehend https; `SESSION_ENCRYPT=false`. | `SESSION_SECURE_COOKIE=true` in allen https-Umgebungen. |
| M10 | Mittel | Security-Header | `app/Http/Middleware/SecurityHeaders.php:11-31` | Gute Header vorhanden, aber **kein HSTS** und **keine CSP** (bewusst zurückgestellt). Bei fremdem HTML-Content wäre CSP wertvolle zweite Linie. | HSTS ergänzen; CSP als eigenes Vorhaben mit Nonce-Strategie (Livewire/Alpine). |
| M11 | Mittel ✅ behoben | Credential-Übergabe | `app/Http/Middleware/RejectLegacyApiKeys.php:56-66` | Legacy-API-Key wird auch aus `query('api_key')` akzeptiert → landet in Access-/Proxy-Logs, Referer, Historie. Key ist gültiges Sanctum-Token. | Query-/Body-Variante entfernt; nur Bearer- und Legacy-Header bleiben zulässig. |
| M12 | Mittel ✅ behoben | Performance | `app/Services/Web/ReleaseDetailPageData.php:64-72` | Write-on-read: UPDATE `hits` + INSERT Event bei jedem GET → meistfrequentierte Seite nie response-cachebar, 2 Writes/View. | Zähler/Event per `defer()` nach die Response verlagert; Hit-Inkrement atomar. |
| M13 | Mittel ✅ behoben | Performance / Caching | `app/Services/Web/SuchePageData.php` · `NewsroomDetailPageData.php` | Einzige Web-`PageData` **ohne** `Cache::remember`; NewsroomDetail ~10+ Aggregate/Request. | Cache nach Portal/Sprache/Slug und Paginierungsseite ergänzt (TTL 300 s). |
| M14 | Mittel | Konsistenz / Autorisierung | `app/Http/Controllers/Api/V1/PressReleaseController.php:29,79,146,198` | Zwei Autorisierungsstile: Policies (Web/PDF) vs. manuelle `abort_unless(tokenCan())` + Owner-Scopes (API). Reviewer sieht Coverage nicht aus der Policy. | Ownership über `PressReleasePolicy`/`CompanyPolicy` routen; `tokenCan()` nur für Token-Scope. |
| M15 | Mittel | Duplikation | `resources/views/web/presseecho.blade.php` · `businessportal24.blade.php` | Zwei nahezu identische Landingpages (je 59 Z., 4 abweichende, aus Portal/Theme ableitbare Stellen); `HomeController:21-24` verzweigt. Jede Änderung doppelt. | Zu einer `web/home.blade.php` zusammenführen (Abschnitt 4). |
| N1 | Niedrig | Queues / Error Handling | `app/Jobs/ClassifyPressRelease.php:32,64-73` · `ScorePressRelease.php:29,53-62` | `tries=3`, aber kein `backoff`/`failed()`; jeder Versuch schreibt `KiAudit`-Zeile ohne Dedup → Duplikate bei Retry. | `backoff` setzen; Audit-Write idempotent/letzter Schritt. |
| N2 | Niedrig | Offener Redirect | `livewire/customer/company-switcher.blade.php:47` · `admin/portal-switcher.blade.php:39` | `redirect(request()->headers->get('referer'))` — client-kontrollierter Header ohne Whitelisting (praktisch same-origin). | Auf interne Route-Namen/Pfade beschränken bzw. Host-Check. |
| N3 | Niedrig | XSS / Validierung | `web/newsroom.blade.php:84` · `admin/users/edit.blade.php:392` u. a. | `company->website`/`backlink_url` mit `url`-Default-Regel (erlaubt `ftp`/`skype`/…); `profile.backlink_url` im Admin ganz ohne `url`-Regel. `javascript:` scheitert bereits. | Flächendeckend `url:http,https`; Admin-Regel angleichen. |
| N4 | Niedrig | Logic / Business Rule | `app/Console/Commands/ResetMonthlyPressReleaseQuota.php:33-34` · `routes/console.php:80-84` | Quota-Reset kalendarisch (1. des Monats), nicht am Stripe-Billing-Anker → Bonus-Monat bei Abschluss am Monatsende. **Zu verifizieren** ob gewollt. | Produktentscheidung; ggf. an Subscription-Periode ankern. |
| N5 | Niedrig | CSRF / Design | `app/Http/Controllers/EmailUnsubscribeController.php:25-46` | GET ändert Zustand (Abmeldung). Durch unbefristete signierte URL geschützt, aber Link-Prefetcher/Scanner können ungewollt abmelden. | GET nur Bestätigung; Austragung per POST-Button (RFC-8058-POST bleibt). |
| N6 | Niedrig | Dead Code | `routes/domains.php:44-52,35-37,22` · `resources/css/web/test.css` · `theme-main.css:3` | Leere Domain-Route-Gruppen, auskommentierte Blöcke, verwaiste CSS (`test.css` Tailwind v3; `@source` auf nicht existente `layout.blade.php`). | Löschen/Pfad korrigieren. |
| N7 | Niedrig | Performance | `app/Http/Controllers/Web/SitemapController.php:111-116` | Volles Archiv per `->get()` in Speicher (3-Spalten + 1h-Cache mildern). Risiko 50k-URL/50MB-Limit + Memory-Spike bei Cache-Miss. | Sitemap-Index + `chunk()`/`cursor()`. |
| N8 | Niedrig | DB-Index | `HomePageData.php:110` · `CategoryPageData.php:153` · `SuchePageData.php:25` | `orderByDesc('hits')` ohne passenden Index → Filesort über gefilterten Archiv (v. a. uncachedes `SuchePageData`). | Composite-Index `(portal,language,status,deleted_at,hits)`. |
| N9 | Niedrig | CORS | fehlende `config/cors.php` | Framework-Default (`allowed_origins=['*']`, aber `supports_credentials=false`, nur `api/*`). API ist Bearer-basiert → Risiko gering. | Explizit auf bekannte Origins beschränken. |
| N10 | Niedrig | DB-Benutzer | `.env` `DB_USERNAME=root` | Alle drei Verbindungen als `root`. Lokal tolerierbar, produktiv nie. | Least-Privilege-DB-User. |
| N11 | Niedrig | Duplikation / DRY | `app/Http/Controllers/CheckoutController.php:37-43,71-77,…` | Fünf Checkout-Actions mit identischem Billing-/Unternehmer-Guard-Prelude. | Middleware `EnsureBillingReady` oder `guardCheckout()`-Helper. |
| N12 | Niedrig | Konsistenz | `Api/V1/PressReleaseController.php:153-155` u. a. | Inline-`$request->validate()` neben etablierten Form Requests (nur 4 FR, alle API). | `submit()` etc. in Form Requests promoten (optional). |
| I1 | Info | Debug/Config | `.env` `APP_DEBUG=true`, `APP_ENV=local` | Lokal ok; gefährlich sobald abgeleitete `.env` produktiv läuft (Live-URLs bereits gesetzt → dev/prod-Trennung unscharf). | Für Nicht-Dev `APP_DEBUG=false`, `APP_ENV=production` erzwingen. |
| I2 | Info | SQLi (Zu verifizieren) | `app/Services/Admin/AdminSlowRequestReporter.php:302` | `DB::select($prefix.$sql)` führt `EXPLAIN` auf geloggten SQL-Texten aus (App-eigenes Query-Log, `isExplainable()` beschränkt auf SELECT). Kein User-Input-Pfad gefunden. | Zusätzlich Mehrfach-Statements (`;`) per Regex ausschließen. |
---
## 3. Detailabschnitte (Kritisch & Hoch)
### K1 — Rechteausweitung: `editor` → Super-Admin
**Kette (verifiziert):**
1. `EnsureUserIsAdmin` prüft nur das Gate `access-admin` (`app/Http/Middleware/EnsureUserIsAdmin.php:20`).
2. `access-admin` = `canAccessAdmin()`, das `editor` einschließt:
```php
// app/Models/User.php:471
public function canAccessAdmin(): bool
{
if (! $this->is_active) { return false; }
if ($this->is_super_admin) { return true; }
return $this->hasAnyRole(['admin', 'editor']); // ← editor darf in den Admin-Bereich
}
```
3. Der Seeder gibt `editor` bewusst **nicht** `users:manage`/`roles:manage` (`database/seeders/RolesAndPermissionsSeeder.php:34-35,47-55`) — die Granularität ist gewollt.
4. Die Volt-Komponenten `admin/users/*` und `admin/roles/*` erzwingen sie aber nirgends. `save()` schreibt ungeprüft aus dem Formular:
```php
// resources/views/livewire/admin/users/edit.blade.php:415
$user->update([
// ...
'is_active' => $this->isActive,
'is_super_admin' => $this->isSuperAdmin, // ← Checkbox aus dem Formular (:856)
]);
$user->syncRoles($validated['selectedRoles']); // ← 'admin' via Rule::exists('roles','name') erlaubt
```
`grep` nach `authorize`/`->can(`/`abort`/`users:manage` in diesen vier Komponenten: **null Treffer**. `mount()` (`edit.blade.php:139`) hat keinen Guard. Analog `admin/roles/edit.blade.php:67` (`syncPermissions` ohne `roles:manage`): ein `editor` kann der eigenen Rolle beliebige Permissions geben.
**Warum kritisch:** Ein `editor` ruft `/admin/users/{eigeneId}/edit` auf, setzt `is_super_admin=true` (oder weist sich `admin` zu) und erlangt vollständige Kontrolle — inklusive aller ihm konzeptionell entzogenen Permissions. Die Inkonsistenz ist im Code belegt: Impersonation prüft `users:manage` (`UserImpersonation.php:15`), PM-Publish kennt `press-releases:publish` — nur die User-/Rollen-CRUD nicht.
**Fix:**
```php
// in mount() UND jeder Schreibmethode von admin/users/* und admin/roles/*
abort_unless(auth()->user()->can('users:manage'), 403); // bzw. 'roles:manage'
// is_super_admin / privilegierte Rollen zusätzlich absichern:
$targetSuperAdmin = auth()->user()->is_super_admin
? $this->isSuperAdmin
: $user->is_super_admin; // Nicht-Super-Admin darf das Flag nicht ändern
$user->forceFill(['is_super_admin' => $targetSuperAdmin])->save();
```
Sauberer: `UserPolicy`/`RolePolicy` einführen und `$this->authorize(...)` nutzen (analog vorhandener Policies). Zusätzlich M8 umsetzen (Flag aus `$fillable`).
---
### K2 — Echte Produktions-Secrets im Klartext in `.env`
**Verifiziert:** `.env` ist **nicht** in Git (`git ls-files --error-unmatch .env` schlägt fehl) — korrekt. Die Datei enthält aber reale Live-Geheimnisse:
- `OPENAI_API_KEY=sk-proj-…` — echter `sk-proj`-Key (verifiziert vorhanden, kein Test-Platzhalter).
- `GOOGLE_CLIENT_SECRET=GOCSPX-…` mit Live-`GOOGLE_REDIRECT_URI` (`pressekonto.com`).
- `MAIL_PASSWORD=…` (aktiv) plus ein zweites, auskommentiertes Live-Mailpasswort; auskommentiertes Live-DB-Passwort unter „Live Credentials".
**Warum kritisch:** Klartext-Live-Secrets auf dem Host, teils historisch auskommentiert (= nicht rotiert). In Kombination mit `APP_DEBUG=true` (I1) und fehlender Host-Whitelist (H5) steigt die Exponierungsfläche.
**Fix:** OpenAI-, Google- und aktives Mail-Secret **rotieren** und als kompromittiert behandeln. Auskommentierte Live-Credentials vollständig entfernen. Secrets über Deployment-Env/Secret-Manager bereitstellen, nicht als Datei.
---
### H1 — Impersonation ohne Rangprüfung
```php
// app/Actions/Admin/UserImpersonation.php:26
public function canBeImpersonated(User $target): bool
{
return $target->canAccessCustomer(); // gilt auch für Admins/Super-Admins
}
```
Admins erfüllen `canAccessCustomer()`. Ein Initiator mit `users:manage` (selbst kein Super-Admin) kann daher einen **Super-Admin** impersonieren. Der Admin-Bereich ist währenddessen gesperrt (`BlockAdminDuringImpersonation`), der **Kundenbereich des Ziels** aber voll — inkl. **API-Token-Erstellung** (`resources/views/livewire/customer/tokens.blade.php`, `auth()->user()->createToken(...)`). Ein so erzeugtes Token überlebt das Verlassen der Impersonation → persistenter Zugriff im Namen des höher privilegierten Kontos. (Das Impersonation-**Grundgerüst** ist ansonsten sauber: `canInitiate` serverseitig, keine verschachtelte Impersonation, Leave validiert `canAccessAdmin()` des Original-Admins.)
**Fix:**
```php
public function canBeImpersonated(User $target): bool
{
return $target->canAccessCustomer()
&& ! $target->canAccessAdmin()
&& ! $target->is_super_admin;
}
```
Optional: Token-Erstellung während aktiver Impersonation unterbinden.
---
### H2 — Admin-CRUD/Publish nur grob `access-admin`-gated (systemisch)
Keine der folgenden mutierenden Aktionen prüft eine spezifische Permission; die Absicherung endet am `access-admin`-Gate (= admin **oder** editor):
- `admin/companies/edit.blade.php:187` `deleteCompany()`, `:104` `update()`
- `admin/press-releases/index.blade.php:220,244,259` `publish()/reject()/archive()`; `edit.blade.php:271,337` `publish()/deletePressRelease()`
- `admin/categories/edit.blade.php:134` `delete()`; `admin/footer-codes/edit.blade.php:85` `delete()`; `admin/contacts/index.blade.php:351` `deleteContactFromIndex()`; `admin/payments/index.blade.php:40` `cancelBundle()`
Für **Veröffentlichen** existiert die Permission `press-releases:publish` (die `editor` hat), sie wird aber weder in der Komponente noch in `PressReleaseService::publish()` per `authorize` erzwungen. **Zu verifizieren**, ob `editor` bewusst Vollzugriff auf all diese Aktionen haben soll. Falls einzelne (Firmen/PMs löschen, Bundle kündigen) nur `admin` vorbehalten sein sollen, fehlt die Prüfung.
**Fix:** Pro Aktion Zielrecht definieren und via `authorize('...')`/`can:`-Route erzwingen; Publish konsistent gegen `press-releases:publish`.
---
### H3 — Stored-XSS via `page_url` (javascript-URI) im Admin-Backend
```php
// app/Http/Controllers/Web/ContactController.php:40
'page_url' => ['nullable', 'string', 'max:500'], // ← keine URL-Regel
```
```blade
{{-- resources/views/livewire/admin/contact-messages/show.blade.php:166 --}}
<a href="{{ $message->page_url }}" target="_blank" rel="noopener">
```
`page_url` stammt aus dem **anonymen** Kontaktformular. Blade-Escaping verhindert keine `javascript:`-URLs. Ein anonymer Besucher schleust `javascript:fetch('/admin/...')` ein; klickt ein Admin den Link im Backend, läuft der Code im Session-/Privilegien-Kontext des Admins.
**Fix:** `'page_url' => ['nullable', 'url:http,https', 'max:500']`; defensiv beim Rendern:
```blade
@if ($message->page_url && Str::startsWith($message->page_url, ['http://', 'https://']))
<a href="{{ $message->page_url }}" target="_blank" rel="noopener">{{ $message->page_url }}</a>
@else
<span class="break-all">{{ $message->page_url }}</span>
@endif
```
---
### H4 — Doppelte Gutschrift bei gleichzeitigem Stripe-Webhook
```php
// app/Services/Billing/CreditTopupService.php:70
if ($topup->credited_at !== null) { return; } // Guard AUSSERHALB der Transaktion, ohne Lock
// ...
DB::transaction(function () use ($topup, ...) {
$wallet->credit(...); // additiv
$topup->update(['credited_at' => now()]);
});
```
`credit_transactions` hat **keine** Unique auf `reference` (`2026_06_17_141219_create_credit_transactions_table.php` nur `nullableMorphs`). Stripe liefert Webhooks at-least-once, Cashier verarbeitet sie synchron pro HTTP-Request. Zwei nahezu gleichzeitige Zustellungen (Original + Retry) lesen beide `credited_at === null`, passieren beide den Guard und **gutschreiben zweimal**. (Der `fulfillSinglePurchase()`-Pfad hat dieselbe Form, ist aber idempotent in der Folgewirkung — nur der additive Top-up ist scharf.)
**Fix:**
```php
DB::transaction(function () use ($topup, ...) {
$topup = CreditTopup::whereKey($topup->id)->lockForUpdate()->first();
if ($topup->credited_at !== null) { return; }
$wallet->credit(...);
$topup->update(['credited_at' => now()]);
});
```
Alternativ/zusätzlich Unique-Index auf `credit_transactions (reference_type, reference_id, type)`.
---
### H5 — Host-Header-Injection (alle Proxies + `X-Forwarded-Host`)
```php
// bootstrap/app.php:79
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | ...
);
```
Es gibt **keine** `trustHosts`-Whitelist (grep: kein Treffer). Damit bestimmt der Client per `X-Forwarded-Host` den Host, den Laravel für URL-Generierung nutzt: vergiftbare absolute Links in Passwort-Reset-/Verify-Mails, **signierten** Abmelde-/Verify-URLs (Host geht in die Signaturbasis ein) und Cache-Poisoning. In einem reinen Traefik/Docker-Setup ist `at:'*'` nur unbedenklich, wenn die App ausschließlich über den Proxy erreichbar ist — das erzwingt die App selbst nicht.
**Fix:**
```php
->trustHosts(at: ['pressekonto.de', 'presseecho.de', 'businessportal24.de']) // reale Domains
```
Nach Möglichkeit Proxy-Vertrauen auf das bekannte Proxy-Subnetz statt `*` einschränken.
---
### H6 — Veraltete Packages mit HIGH-CVEs
`composer audit`: **19 Advisories / 10 Packages**. Relevant:
- **HIGH** `symfony/http-kernel` CVE-2026-45075 (HEAD-Request umgeht `methods:['GET']`-Filter in `#[IsGranted]`/CSRF-Attributen).
- **HIGH** `symfony/mime` CVE-2026-45067 (E-Mail-Header/SMTP-Injection via CRLF in `Address`).
- **HIGH** `laravel/framework < 12.60` (CRLF im default email-Rule) — Projekt ist **12.57**, betroffen; ferner „Temporary Signed URL Path Confusion" (medium) `< 12.61.1`.
- Diverse **medium** in `guzzlehttp/guzzle`, `guzzlehttp/psr7`, `symfony/routing`, `symfony/mailer`, `symfony/http-foundation` (SSRF-Bypass), `symfony/polyfill-intl-idn`, `symfony/yaml`.
**Fix:** `composer update` auf gepatchte Minor-Versionen (Framework ≥ 12.61.1, Symfony ≥ 7.4.13 / 6.4.41, Guzzle ≥ 7.12.1, psr7 ≥ 2.12.1), danach `composer audit` erneut + Test-Suite.
---
### H7 — Uncached Navigations-Chrome auf jeder Seite
```php
// app/Support/CategoryNavigation.php:35 — pro Request, uncached
Category::query()->topLevel()...
->withCount(['pressReleases as published_count' => ...]) // korrelierte Aggregatabfrage übers ganze Archiv
```
Aufgerufen in `site-header.blade.php:36` (`items(null)` = alle Kategorien), `site-footer.blade.php:49`, `industry-index.blade.php:6`, `livewire/web/search.blade.php:68`. Jeder anonyme Seitenaufruf zahlt ≥2 uncachede Count-Aggregate — obwohl die Seitendaten selbst (`HomePageData`/`CategoryPageData`/`TickerData`) sorgfältig 300s gecacht sind.
**Fix:**
```php
return Cache::remember("catnav:{$portal}:{$language}:".($limit ?? 'all'), 300,
fn () => /* aktuelle Query */);
```
Bester Einzel-Perf-Gewinn, geringes Risiko.
---
## 4. Duplikations-Konsolidierungsplan (die zwei Frontends)
**Kernaussage:** Die vermutete Duplikation ist **weitgehend bereits gelöst**. Beide Spokes teilen Layout, Komponenten, Controller, Routen, CSS-Tokens und Daten-Services. Was gut ist und **so bleiben soll**:
- Domain→Theme einmalige Auflösung in `ThemeServiceProvider` + `DomainAssetContext`; beide Spokes `view_prefix='web'`, `assets_dir='build/web'`.
- Token-basierte Themes: `theme-presseecho.css`/`theme-businessportal24.css` importieren `shared-styles.css` und redefinieren dieselben `@theme`-Tokennamen mit anderen Werten. **Nicht zusammenführen** — die per-Brand-Werte sind der beabsichtigte Variationspunkt.
- Config-aware Komponenten (`x-web.site-header`/`site-footer` lesen `config('app.theme')`), zentraler `WebPortalContext::fromRequest()`.
**Einzige echte Copy-Paste-Stelle — Landingpages (M15):**
`presseecho.blade.php` vs. `businessportal24.blade.php` (je 59 Z.) differieren an genau 4 Stellen, alle zur Renderzeit ableitbar:
- L4 Brand: `config('domains.domains.<theme>.brand')`
- L9-13 Meta-Fallbacks: bereits als `brand.meta_title`/`meta_description` in `config/domains.php` vorhanden
- L32 Portal: `WebPortalContext::portalFromHost()->value`
**Umsetzung (Priorität 1):**
1. `resources/views/web/home.blade.php` mit dem gemeinsamen Body anlegen.
2. Die 4 Stellen durch die o. g. Lookups ersetzen (Brand/Meta aus `config('app.theme')`, Portal aus `WebPortalContext`).
3. `HomeController::__invoke` (`app/Http/Controllers/Web/HomeController.php:21-24`) auf `return view('web.home', $pageData->build(...))` vereinfachen — Verzweigung löschen.
4. `presseecho.blade.php` + `businessportal24.blade.php` löschen.
5. Feature-Test: beide Hosts rendern `web.home` mit korrektem Brand-Namen und Portal-Feed.
**Priorität 2 — Aufräumen (N6):** leere Domain-Route-Gruppen (`routes/domains.php:44-52`), auskommentierte Blöcke (`:22,35-37`), `resources/css/web/test.css`, kaputter `@source` in `theme-main.css:3` löschen/korrigieren.
**Priorität 3 — Konsistenz (M14, N11, N12):** API-Ownership über die vorhandenen Policies routen; Checkout-Guard-Prelude in Middleware extrahieren; Inline-Validierungen optional in Form Requests promoten.
---
## 5. Priorisierte Maßnahmenliste
**Sofort — Sicherheit (blockierend für Produktivbetrieb):**
1. **K1** — `users:manage`/`roles:manage` auf allen `admin/users/*`- und `admin/roles/*`-Komponenten erzwingen; `is_super_admin`/Admin-Rolle nur für Super-Admins. + **M8** (`is_super_admin` aus `$fillable`).
2. **H2** — Zielrechte für destruktive/publizierende Admin-Aktionen definieren und erzwingen (Klärung Rollenmodell `editor`).
3. **K2** — OpenAI-, Google-, Mail-Secrets rotieren; auskommentierte Live-Credentials entfernen.
4. **H1** — Impersonation-Rangprüfung. **H4** — Wallet-Top-up in Transaktion + Row-Lock (echtes Geld).
5. **H3** — `page_url`-Validierung + Render-Guard (trivialer Fix, hoher Impact).
6. **H5** — `trustHosts`-Whitelist. **M9** (`SESSION_SECURE_COOKIE=true`), **I1** (`APP_DEBUG=false` Nicht-Dev) mitnehmen.
7. **H6** — `composer update` auf gepatchte Versionen, `composer audit` grün.
**Danach — Korrektheit/Bugs:**
8. **M2** (Quota-TOCTOU), **M3** (Slug-500) transaktional/lock-basiert absichern.
9. **M1** (Magic-Link 2FA), **M5/M4** (Attachment-MIME + private Disk für unveröffentlichte Medien), **M6** (API-Text sanitizen), **M7** (Newsletter-Rebind), **M11** (Legacy-Key im Query).
10. **N1** (Job-Backoff/Idempotenz), **N4** (Quota-Reset-Modell klären), **N2** (Referer-Redirect), **N3** (`url:http,https`).
**Mittelfristig — Performance/Refactoring:**
11. **H7** (`CategoryNavigation` cachen) — größter Perf-Gewinn, sofort machbar.
12. **M12** (Write-on-read deferred), **M13** (Suche/Newsroom cachen), **N8** (`hits`-Index), **N7** (Sitemap-Chunking).
13. **M15/N6/M14/N11** (Landing-Konsolidierung, Dead Code, Autorisierungs-Vereinheitlichung).
14. **M10** (HSTS jetzt, CSP als Projekt).
**Test-Lücken (empfohlene Feature-Tests):**
- **K1-Regressionstest:** `editor` erhält 403 auf `admin/users`/`admin/roles` und kann `is_super_admin` nicht setzen.
- **H1-Regressionstest:** Impersonation eines Admins/Super-Admins wird abgelehnt.
- **H4-Regressionstest:** doppelte Webhook-Zustellung gutschreibt nur einmal.
- **H3-Regressionstest:** `javascript:`-`page_url` wird abgewiesen/nicht als Link gerendert.
- Query-Budget-Test auf `web.home` (`DB::listen`, Schwelle) — fängt N+1-Regressionen im Chrome/PageData.
- `CategoryNavigation`: Caching + Ordering + Portal/Language-Scope.
- Release-Detail: GET erhöht `hits` um exakt 1 + genau ein `PressReleaseEvent`; nach Deferral Response unverändert.
- Sitemap-Scale (Chunking/URL-Limit); NewsroomDetail-Caching; Boost-Precedence (erweitert `BoostPlacementTest`).
---
## 6. Was gut gelöst ist (nicht anfassen)
- **Domain-/Theme-System** (Abschnitt 0/4) — vorbildlich token-getrieben.
- **Panel-Trennung dicht:** `EnsureUserIsAdmin`/`EnsureUserIsCustomer` als `addPersistentMiddleware` registriert → auch `/livewire/update`-Folge-Requests re-geprüft.
- **IDOR kundenseitig sauber:** `accessibleBy`/`findFor`-Scopes + Policy-`authorize()` an jeder mutierenden Stelle; `#[Locked]` auf Entity-IDs. `ProofPdfController`, `LegacyInvoicePdfController` (`Gate::authorize`), `StripeInvoiceRedirectController` (user_id-Check), Firmen-/Kontakt-Verwaltung vorbildlich.
- **Policies** mit `before()`-Super-Admin-Kurzschluss und Owner/Firmen-Scoping.
- **XSS/Purifier:** enges `press_release`-Profil (nur p/br/h2/h3/strong/em/ul/ol/li/blockquote/a, Schemes http/https/mailto/tel — kein `javascript:`), Sanitization **doppelt** (Speichern + Rendern), `renderedText()` als einziger Sink; Legacy-Plaintext `e()`-escaped; `LinkPolicy` erzwingt `rel`.
- **Bild-Uploads:** inhaltsbasierte MIME-Prüfung, GD-Neukodierung (entfernt Payloads), Extension aus MIME abgeleitet, UUID-Namen, kein SVG.
- **SQL-Injection:** keine gefunden; `whereRaw`/`selectRaw`/`orderByRaw` durchweg parametrisiert bzw. mit hartkodierten Spaltennamen.
- **Login-Härtung:** eigenes Throttling 5/min (E-Mail+IP), Passwortprüfung vor Login (kein 2FA-Bypass), Session-Regeneration, Inaktiv-Sperre; Fortify-Limiter gesetzt.
- **Magic-Link:** Token nur als SHA-256-Hash, atomares Einmal-Einlösen gegen Races (2FA-Umgehung M1 ausgenommen).
- **Billing-Atomizität:** `InvoiceNumberGenerator` (`insertOrIgnore` + unique + `lockForUpdate`, lückenlos), Wallet-Core (`post()` transaktional + Row-Lock + Negativsaldo-Guard), Webhook-Idempotenz (`mirrorPaidInvoice` dedupt auf `stripe_invoice_id`), Stripe-Webhook-Signatur via Cashier verifiziert.
- **Mass Assignment:** alle 44 Models mit expliziten `$fillable`-Whitelists, kein `$guarded=[]`, kein `$request->all()` in Persistenz-Pfaden; `forceFill` durchweg hartkodiert (Ausnahme: `is_super_admin` im `$fillable`, M8).
- **CSRF:** einzige Ausnahme signierte RFC-8058-Abmeldung, Signatur in beiden Handlern geprüft; `verify-email`/`email-aendern` `signed`+`throttle`+auth mit Zusatz-Checks.
- **Fehlerbehandlung:** keine leeren catch-Blöcke in `app/`; kritische Pfade loggen.
- **Timezone:** `config/app.php` `Europe/Berlin`, Scheduler + Stripe-Timestamp-Konvertierungen konsistent — kein Mixing-Bug gefunden (Branch-Ziel erfüllt).
- **Performance-Read-Schicht:** Eager Loading überall, Pagination durchgängig, gezielte Composite-/Fulltext-Indizes, `AppSetting`/`MarketDataService`/`HubPageData`/`Sitemap` gecacht.
- **Öffentliche Formulare** mit Honeypot + RateLimiter + `throttle`; **BasicAuth** timing-sicher (`hash_equals`).
- **Testabdeckung** ~176 Dateien, breit über alle kritischen Domänen.
---
## 7. Fix-Protokoll (Umsetzung)
Chronologische Dokumentation der umgesetzten Fixes. Jeder Eintrag nennt Änderung, Begründung und Testnachweis. Alle Tests laufen mit `php artisan test` (Docker/Sail lokal nicht aktiv).
### 2026-07-14 — K1 (Rechteausweitung `editor` → Super-Admin) + M8 (`is_super_admin` mass-assignable) — ✅ behoben
**Problem:** Die User-/Rollen-Verwaltungskomponenten prüften nur das grobe `access-admin`-Gate (schließt `editor` ein), aber nicht die bewusst nur an `admin` vergebenen Permissions `users:manage`/`roles:manage`. Ein `editor` konnte sich `is_super_admin=true` setzen bzw. die Rolle `admin` zuweisen. `is_super_admin` lag zudem im `$fillable`.
**Änderungen:**
1. `app/Models/User.php` — `is_super_admin` aus `$fillable` entfernt (M8). Wird ab jetzt ausschließlich per autorisiertem `forceFill()` gesetzt.
2. `app/Providers/AppServiceProvider.php` — zentraler `Gate::before`-Super-Admin-Kurzschluss ergänzt. Damit greifen `can('users:manage')`/`can('roles:manage')` auch für Super-Admins ohne passende Spatie-Rolle (analog zu den Policy-`before()`-Methoden).
3. Berechtigungsprüfung `authorize('users:manage')` in `mount()` **und** allen Schreibmethoden von `admin/users` (Index `users.blade.php`, `users/create`, `users/edit`, `users/show`); `authorize('roles:manage')` analog in `admin/roles` (`roles/index`, `roles/create`, `roles/edit`). Da `mount()` serverseitig läuft und Livewire-Tests wie HTTP-Requests dagegen prüfen, ist die Sperre sowohl beim initialen Aufruf als auch bei `/livewire/update` wirksam.
4. `admin/users/edit` + `create` — `is_super_admin` aus dem `update()`/`create()`-Array entfernt und in einen **Super-Admin-only**-`forceFill`-Zweig verschoben: ein Admin mit `users:manage`, der selbst kein Super-Admin ist, kann den Rang nicht (mehr) vergeben.
5. `app/Services/Import/UserImporter.php` — Import setzt `is_super_admin` jetzt per `forceFill()` (da nicht mehr mass-assignable), Verhalten unverändert.
6. `resources/views/components/layouts/app/sidebar.blade.php` — Navigation zu Benutzer/Rollen mit `@can('users:manage')`/`@can('roles:manage')` umschlossen (keine 403-Links mehr für Editoren).
**Tests (neu):** `tests/Feature/Admin/AdminPrivilegeEscalationTest.php` (6 Tests): Editor 403 auf allen User-/Rollen-Seiten; Admin 200; Editor kann Rollen-Komponente nicht mounten; Nicht-Super-Admin kann `is_super_admin` nicht vergeben; Super-Admin kann es; `is_super_admin` nicht mass-assignable.
**Tests (angepasst):** `tests/Feature/Admin/UserImpersonationTest.php` — „editor cannot start impersonation" prüft jetzt die stärkere Sperre auf Seiten-Ebene (403 auf `/admin/users`).
**Ergebnis:** `tests/Feature/Admin` + `tests/Feature/Auth` = **201 passed**; Pint sauber.
### 2026-07-14 — H1 (Impersonation ohne Rangprüfung) — ✅ behoben
**Problem:** `canBeImpersonated()` erlaubte jedes Ziel mit Kundenzugriff, inkl. Super-Admins. Ein Admin (`users:manage`, kein Super-Admin) konnte einen Super-Admin übernehmen und in dessen Namen persistente API-Token erzeugen.
**Präzisierung des Audit-Vorschlags:** Ein bestehender Test (`admin can impersonate accounts with admin access`) belegt, dass Admin→Admin/Editor-Impersonation **gewollt** ist. Ein pauschales Blockieren aller Admin-Ziele wäre also falsch. Der tatsächliche Eskalationspfad ist enger: **nach oben** (auf einen Super-Admin). Fix daher als gezielte Rangprüfung.
**Änderungen:** `app/Actions/Admin/UserImpersonation.php` — neue Methode `mayImpersonateRank(admin, target)`: `admin->is_super_admin || ! target->is_super_admin`. Erzwungen in `canStart()` (UI-Gate) **und** `start()` (Server-Enforcement, wirft `AuthorizationException`). Admin→Admin/Editor bleibt erlaubt; nur Nicht-Super-Admin→Super-Admin ist gesperrt.
**Tests (neu):** `UserImpersonationTest.php` — „non super admin cannot impersonate a super admin" (403) und „super admin can impersonate another super admin" (erlaubt).
**Ergebnis:** `UserImpersonationTest` **7 passed**.
### 2026-07-14 — H3 (Stored-XSS via `page_url`) — ✅ behoben
**Problem:** `page_url` aus dem anonymen Kontaktformular war nur als `string` validiert und wurde im Admin-Backend als klickbarer `<a href>` gerendert → `javascript:`-URI lief beim Admin-Klick im Admin-Kontext.
**Änderungen:**
1. `app/Http/Controllers/Web/ContactController.php` — Regel auf `['nullable', 'url:http,https', 'max:500']` verschärft. Das Feld wird per JS mit der echten Seiten-URL befüllt, daher keine Einschränkung für legitime Submissions.
2. `resources/views/livewire/admin/contact-messages/show.blade.php` — Render-Guard: Link nur bei `http(s)`-Präfix, sonst als reiner Text (Defense-in-depth für Bestandsdaten).
**Tests (neu):** `tests/Feature/Web/ContactFormTest.php` — „a javascript page_url is rejected (stored XSS guard)": `javascript:`-URL → 422, kein `ContactMessage`, keine Mail.
**Ergebnis:** `ContactFormTest` **16 passed**.
### 2026-07-14 — H4 (Doppel-Gutschrift bei Stripe-Webhook-Retry) — ✅ behoben
**Problem:** Der Idempotenz-Guard (`credited_at !== null`) lag **außerhalb** der Transaktion ohne Row-Lock. Zwei at-least-once-Zustellungen, die die Zeile vor der ersten Gutschrift laden, konnten beide gutschreiben (echtes Geld doppelt).
**Änderungen:** `app/Services/Billing/CreditTopupService::fulfill()` — Guard **in** die Transaktion verschoben und die Topup-Zeile per `lockForUpdate()` gesperrt (analog zum bereits vorhandenen Wallet-`post()`). Erst nach dem Lock wird `credited_at` geprüft; parallele Aufrufe serialisieren. (SQLite ignoriert `FOR UPDATE`; der Guard-in-Transaktion ist dennoch testbar und für MySQL-Produktion korrekt.)
**Tests (neu):** `tests/Feature/CreditTopupTest.php` — „two concurrent webhook deliveries credit the wallet only once": zwei separate, vor der Gutschrift geladene Instanzen → nur 1 Credit-Transaktion, Saldo einmal. (Dieser Test schlägt auf dem alten Code fehl.)
**Ergebnis:** `CreditTopupTest` **8 passed**; gesamte `tests/Feature/Billing` **176 passed**.
### 2026-07-14 — H5 (Host-Header-Injection) — ✅ behoben
**Problem:** `trustProxies(at:'*')` inkl. `X-Forwarded-Host` ohne Host-Whitelist → Client kann den für URL-Generierung genutzten Host setzen.
**Änderungen:** `bootstrap/app.php` — `->trustHosts()` mit einer Closure ergänzt, die aus `config('domains.domain_portal|presseecho|businessportal')` anker­gebundene Host-Patterns (`^(.+\.)?host$`) baut. Fremde Host-Header werden verworfen.
**Verifikation:** Laravel erzwingt Trusted Hosts konstruktionsbedingt **nur außerhalb** von `local`/Unit-Tests (`TrustHosts::shouldSpecifyTrustedHosts()`), daher greift der Schutz in Produktion, ohne die Multi-Host-Testsuite zu brechen. App-Boot mit der neuen Closure verifiziert; `tests/Feature/Web` **228 passed** (unverändert).
### 2026-07-14 — M1 (Magic-Link umgeht 2FA) — ✅ behoben
**Problem:** `MagicLinkConsumeController` loggte direkt ein und übersprang aktiviertes 2FA.
**Änderungen:** `app/Http/Controllers/Auth/MagicLinkConsumeController.php` — bei `hasEnabledTwoFactorAuthentication()` wird nun (wie im Passwort-Login) `login.id`/`login.remember` in die Session gelegt und auf `two-factor.challenge` umgeleitet, statt einzuloggen. Der Link ist bereits atomar verbraucht (Einmal-Nutzung bleibt).
**Tests (neu):** `tests/Feature/Auth/MagicLinkLoginTest.php` — „magic link login with two-factor enabled is handed to the challenge": Redirect auf Challenge, Gast-Status, `login.id` gesetzt, Link verbraucht.
**Ergebnis:** `MagicLinkLoginTest` + `TwoFactorLoginTest` **19 passed**.
### 2026-07-14 — M6 (API-Text ungefiltert gespeichert) — ✅ behoben
**Problem:** `store()`/`update()` der V1-API speicherten `text` ungefiltert (Web-Pfade sanitizen über `PressReleaseHtmlSanitizer`).
**Änderungen:** `app/Http/Controllers/Api/V1/PressReleaseController.php` — neuer Helper `sanitizeHtmlText()`, aufgerufen in `store()` und `update()`. Sanitisiert HTML per HTMLPurifier (`clean()`), lässt aber Legacy-Plaintext unangetastet (nur wenn `isHtml()`), analog zum `render()`-Vertrag — so werden `<`/`&` in Klartext-Einreichungen nicht zerstört.
**Tests (neu):** `tests/Feature/Api/V1/PressReleaseApiTest.php` — „api submitted html text is sanitized before persistence": `<script>`/`javascript:` werden entfernt, sauberes `<p>` bleibt.
**Ergebnis:** `PressReleaseApiTest` + `PressReleaseSubmitApiTest` **15 passed**.
### 2026-07-14 — H7 (Uncached Navigations-Chrome) — ✅ behoben
**Problem:** `CategoryNavigation::items()` rechnete pro Request Count-Aggregate über das gesamte Archiv — auf jeder Web-Seite (Header/Footer/Branchenindex/Suche), obwohl die Seitendaten selbst gecacht sind.
**Änderungen:** `app/Support/CategoryNavigation.php` — `items()` in `Cache::remember` (300s, Key `web.category-nav.{portal}.{language}.{limit}`) gewrappt, Query in private `query()` ausgelagert. Gleiche TTL-Strategie wie `TickerData`.
**Tests (neu):** `tests/Feature/Web/CategoryNavigationCacheTest.php` — zweiter Aufruf ohne DB-Query (Query-Log leer); separate Cache-Keys je Portal/Sprache/Limit.
**Ergebnis:** 2 passed.
### 2026-07-14 — H2 (Admin-CRUD nur grob `access-admin`-gated) — ✅ behoben (Entscheidung: an Seeder-Rechte gekoppelt)
**Problem:** Destruktive/verwaltende Admin-Aktionen (Firmen/Kontakte/Rubriken/Footer-Codes löschen, Billing, Compliance-Queues) waren nur durch `access-admin` (admin **oder** editor) geschützt. Ein `editor` konnte sie ausführen, obwohl er als reine Content-Rolle gedacht ist.
**Entscheidung des Nutzers:** An die Seeder-Rechte koppeln — Editor behält Content (PMs), verliert destruktive CRM-/Billing-/Compliance-/Struktur-Aktionen.
**Änderungen:**
1. `database/seeders/RolesAndPermissionsSeeder.php` — neue admin-only Permissions: `companies:write`, `categories:manage`, `footer-codes:manage`, `billing:manage`, `compliance:manage` (nur `admin` via `syncPermissions($permissions)`; Super-Admin ohnehin über `Gate::before`).
2. `routes/admin.php` — Route-Gruppen mit `can:`-Middleware: Kategorien → `categories:manage`, Footer-Codes → `footer-codes:manage`, Billing (Rechnungen/Zahlungen/Tarife/Gutscheine/Newsletter) → `billing:manage`, Recht & Kontakt-Queues → `compliance:manage`. Firmen-**Lesen** (Index/Show) bleibt offen (Editor hat `companies:read`), **Schreiben** (create/edit/Kontakt-Formulare) → `can:companies:write`. Show-Route auf `[0-9]+` beschränkt (sonst fängt `{id}` das Segment `create`).
3. Methoden-Guards (Defense-in-depth) via `$this->authorize(...)`: `companies:write` in Firmen-`update`/`deleteCompany`/`save` und Kontakt-`save`/`deleteContactFromIndex`; `press-releases:publish` in PM-`publish`/`reject`/`archive`/`deletePressRelease` und `press-releases:write` in PM-`save` (für Editor unverändert, blockt künftige geringer privilegierte Rollen).
4. `resources/views/components/layouts/app/sidebar.blade.php` — Navigation der gesperrten Bereiche mit `@can(...)` umschlossen (keine 403-Links für Editoren).
**Deployment-Hinweis:** `php artisan db:seed --class=RolesAndPermissionsSeeder` (bzw. `migrate --seed`) muss laufen, damit die neuen Permissions existieren und `admin` sie erhält.
**Tests (neu):** `tests/Feature/Admin/EditorPermissionBoundaryTest.php` (5 Tests): Editor 403 auf Kategorien/Footer-Codes/Billing/Coupons/Invoices/Legal/Kontakt; Editor behält PM- + Firmen-Lese-Zugriff; Editor kann keine Firmen-Schreibformulare öffnen und keinen Kontakt löschen; Admin behält Vollzugriff.
**Ergebnis:** betroffene Admin-/Category-Suiten **134 passed**; volle Suite **1248 passed**.
### 2026-07-14 — H6 (Package-CVEs) — ✅ behoben (Freigabe erteilt)
**Problem:** `composer audit` meldete 19 Advisories (u. a. HIGH `symfony/http-kernel` CVE-2026-45075, `symfony/mime` CVE-2026-45067, `laravel/framework <12.60`).
**Änderungen:** `composer update laravel/framework symfony/* guzzlehttp/* --with-all-dependencies` innerhalb der bestehenden Constraints (keine Major-Upgrades). Ergebnis-Versionen: laravel/framework **12.63.0**, symfony/http-kernel **7.4.14**, symfony/mime **7.4.13**, guzzlehttp/guzzle **7.14.1**, guzzlehttp/psr7 **2.12.5** — alle über den verwundbaren Schwellen.
**Verifikation:** `composer audit` → **„No security vulnerability advisories found."** Volle Testsuite nach Update **1248 passed**.
### 2026-07-14 — K2 (Secrets) — ⚠️ teilweise behoben (Code-Teil erledigt, Rotation offen)
**Erledigt:** Auskommentierte Live-Credentials mit echten Werten aus `.env` entfernt (Live-DB-Passwort-Block, Live-Mail-Passwort-Block, veralteter auskommentierter `STRIPE_WEBHOOK_SECRET`). App-Boot danach verifiziert (`config:show app.name` ok). `.env` ist weiterhin gitignored.
**Offen (manuell, dringend):** Die **aktiven** Secrets (`OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET`, aktives `MAIL_PASSWORD`) sind als kompromittiert zu behandeln und über den jeweiligen Anbieter zu **rotieren** — das ist kein Code-Schritt. Empfehlung: Bereitstellung künftig über Secret-Manager/Deployment-Env statt Datei.
### 2026-07-14 — Restkorrekturen M2–M7 und M11–M13 + H2-Defense-in-depth — ✅ behoben
**Sicherheit und Korrektheit:**
1. `PressReleaseService::publish()` und der Admin-Statuswechsel sperren Pressemitteilung und User-Zeile transaktional; Einzelkäufe werden ebenfalls gesperrt. Ohne verfügbaren Slot bleibt die PM in Review.
2. `HasUniqueSlug::saveWithUniqueSlugRetry()` fängt konkurrierende Unique-Verletzungen ab und berechnet einen neuen Suffix. V1-, Legacy-, Admin- und Kunden-Persistenzpfade nutzen den Retry.
3. Legacy-PM-HTML wird vor Persistenz mit demselben Purifier wie V1/Web bereinigt.
4. Neue PM-Bilder und Anhänge liegen auf der privaten `local`-Disk. Kontrollierte Medienrouten erlauben anonyme Zugriffe nur für tatsächlich archiv-sichtbare PMs; sonst greift die `PressReleasePolicy`. `press-releases:privatize-media` migriert vorhandene öffentliche Bestandsdateien.
5. Die Attachment-Storage-Schicht prüft nun zusätzlich den serverseitig ermittelten MIME-Type.
6. Newsletter-Re-Subscribes erhalten den bestätigten Zustand; ein fremder API-User kann `user_id` und Profildaten nicht übernehmen.
7. Legacy-Schlüssel werden nur noch als Bearer-, `X-ApiKey`- oder `X-Api-Key`-Header akzeptiert, nicht mehr in Query oder Body.
**Autorisierung:** Direkte Livewire-Aufrufe für Kategorien, Footer-Codes, Billing/Newsletter und Compliance prüfen nun ihre Zielpermission in der mutierenden Methode. Der Legacy-KI-Schalter hat die neue admin-only Permission `settings:manage` und ist für Editoren weder sichtbar noch aufrufbar.
**Performance:** Detailseiten schreiben Hits und View-Events per `defer()` nach der Response; das Hit-Inkrement ist atomar. Suche-Seitenleiste und Newsroom-Detaildaten sind 300 Sekunden nach Portal/Sprache bzw. Slug/Paginierungsseite gecacht.
**Gezielte Regressionstests:** Quota-Endslot, Slug-Unique-Retry, Legacy-Sanitizing, Header-only-API-Key, Newsletter-Ownership/Confirmation, private Medienzugriffe und Bestandsmigration, Deferred-Tracking sowie PageData-Caches.
### 2026-07-14 — Zweite Codeprüfung: API-, Billing- und Concurrency-Restpunkte — ✅ behoben
**Security:** Mutierende `/service/*`-Endpunkte erzwingen jetzt dieselben Sanctum-Abilities wie die V1-API. Während einer aktiven Benutzer-Impersonation können keine persistenten API-Tokens erstellt oder widerrufen werden.
**Billing und Kontingente:** Ein Unique-Index erzwingt die Eindeutigkeit von `stripe_invoice_id`; Stripe-Rechnungsspiegelung, Depublikationskäufe, Proof-PDF-Käufe und MAN-Rechnungen revalidieren ihren Zustand unter Row-Lock. Boost-Inventare werden über stabile DB-Lockzeilen je Placement/Portal/Sprache/Rubrik serialisiert; Review-Frei- und Tageskontingente unter User-Lock neu geprüft.
**Status und Medien:** Blacklist-Prüfung und Veröffentlichung laufen unter demselben PM-Lock; Reject ist ebenfalls serialisiert. Die Bestandsmedien-Migration ändert den Datenträger erst, wenn alle Dateien vorhanden sind. Mediencontroller berechnen die öffentliche Sichtbarkeit nur einmal je Request. Soft-gelöschte PMs werden weder vom Scheduler noch vom Legacy-Tracking erfasst.
**Runtime/Performance:** Legacy-Hits werden deferred und atomar geschrieben. Das Newsrooms-Verzeichnis ist sicher nach Portal, Sprache, normalisiertem Suchterm und Seite gecacht. Formular-/Magic-Link-Mails laufen über die Queue; OpenAI-, Markt- und USt-ID-Aufrufe nutzen Connect-Timeouts und begrenzte Retries. Der Scheduler besitzt einen passenden Composite-Index; Lazy-Loading-Warnungen sind lokal aktiv; Admin-Memo-Caches werden konsistent invalidiert.
**KI-Prompt-Injection:** Auf Nutzerentscheidung bleibt das Autopublishing für Grün/Gelb bestehen. Der Systemprompt behandelt PM-Inhalte ausdrücklich als nicht vertrauenswürdige Daten, wertet Manipulationsanweisungen als K6/Rot und übergibt Titel/Text gekapselt als JSON. **Restrisiko:** Prompt-Härtung ist keine formale Sicherheitsgarantie; vollständige Eliminierung erfordert manuelle Freigabe.
**Neue Migrationen:** `create_boost_slot_locks_table`, `add_unique_stripe_invoice_id_to_invoices_table`, `add_scheduled_publication_index_to_press_releases_table`.
---
## 8. Gesamtstatus & offene Punkte (Stand 2026-07-14)
**Verifikation gesamt:** komplette Suite **1273 passed** (5562 assertions), Pint sauber, `composer audit` clean.
**Behoben (mit Tests):** K1, K2*, M1–M8, M11–M13, H1–H7 — **alle kritischen, hohen und die priorisierten mittleren Befunde**. (*K2 = Code-Teil; Secret-Rotation bleibt manueller Ops-Schritt, siehe unten.)
**Offen — benötigt manuellen/Deployment-Schritt (nicht im Code lösbar):**
- **K2 (Secret-Rotation):** Rotation von OpenAI-/Google-/Mail-Keys ist ein manueller Ops-Schritt und **dringend**. Die auskommentierten Live-Credentials wurden bereits entfernt.
- **M9/I1 (`SESSION_SECURE_COOKIE`, `APP_DEBUG`):** Produktiv-`.env`/Deployment — lokal bewusst nicht geändert (`SESSION_SECURE_COOKIE=true`, `APP_DEBUG=false`, `APP_ENV=production`).
- **H2-Deployment:** `php artisan db:seed --class=RolesAndPermissionsSeeder` auf allen Umgebungen ausführen (neue Permissions).
- **M4-Deployment:** einmalig `php artisan press-releases:privatize-media` ausführen, damit vorhandene PM-Medien von `public` nach `local` verschoben werden.
- **Migration-Preflight:** Vor `migrate --force` prüfen, ob `invoices.stripe_invoice_id` bereits Duplikate enthält. Etwaige Doppelrechnungen fachlich abgleichen; die Unique-Migration bricht bewusst ab, statt Rechnungsdaten automatisch umzuschreiben.
**Weiterhin nachgelagert/niedrig priorisiert:** M9/M10/M14/M15 sowie N1–N12; diese Punkte sind nicht Teil des sicherheitskritischen Restkorrektur-Batches und enthalten teils Produkt-/Deploy-Entscheidungen.