docs: Audit-Protokoll, KI-Kennzeichnung & Release-Notizen
- dev/optimize/optimize-14-07-2026.md: Security-/Code-Audit inkl. Fix-Protokoll (Befunde gegen den Code verifiziert). - docs/weiteres/KI-Kennzeichnung.md, Pm Tracken.md, Decision-Updates und Release-/Entwicklungsplan aktualisiert. Hinweis: Der Audit-Bericht enthält keine echten Geheimnisse (nur die öffentlichen Key-Präfixe), aber eine vollständige Befundübersicht. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
198ce15141
commit
6f6cb13f2e
9 changed files with 674 additions and 6 deletions
BIN
dev/frontend/booking/Bildschirmfoto 2026-07-07 um 14.09.46.png
Normal file
BIN
dev/frontend/booking/Bildschirmfoto 2026-07-07 um 14.09.46.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
BIN
dev/frontend/booking/Bildschirmfoto 2026-07-07 um 14.09.57.png
Normal file
BIN
dev/frontend/booking/Bildschirmfoto 2026-07-07 um 14.09.57.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
519
dev/optimize/optimize-14-07-2026.md
Normal file
519
dev/optimize/optimize-14-07-2026.md
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
# 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')` ankergebundene 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.
|
||||
|
|
@ -20,7 +20,7 @@ R2.2+R2.3 erledigt: Hub-Landing komplett ueberarbeitet (datengetriebenes Tarif-G
|
|||
**06.07. — Umami eingebaut + Klaerungsliste KOMPLETT beantwortet:** Umami-Snippets (dev/release/umami.md, sys.adametz.media) je Brand in `config/domains.php` (`brand.umami_website_id`), gerendert via `x-web.umami` im web-master-Layout — per `UMAMI_ENABLED=false` deaktiviert bis zum Go-Live (User-Vorgabe; Checklisten-Punkt in WS-7 ergaenzt). User bestaetigt: Server-Fristen (Logs 14 Tage, Backups 4 Wochen) stimmen, Stripe-Entitaet (Stripe Payments Europe, Dublin) passt. **Inhaltliche Freigaben erteilt:** AGB par. 1 (3) Portalliste via Hub-#familie ok · AGB par. 11 (3) Kuendigungsseiten-Ergaenzung ok · Depublikations-Preis neutral ohne Betrag ok (bis T12) · Medienprivileg-Zitierung bleibt vorsichtig (par. 57 Abs. 1 MStV) bis anwaltliche Praezisierung · /melden-Aenderungsweg ueber Magic-Links fuer Launch ok. Damit sind die Rechtstexte inhaltlich final; offen nur noch [EXTERN] (anwaltliche Freigabe, Postfach info@pressekonto.com).
|
||||
**06.07. — R3.1-Restbacklog KOMPLETT umgesetzt (Suite: 956 gruen):** (a) E3-Versionierung: `legal_documents`-Tabelle (type/version/effective_from, Fassungen unloeschbar, zukuenftige erst ab Stichtag; `LegalDocument::current()`, Seeder mit den 5 aktuellen Fassungen) + `legal_document_id` an `booking_confirmations` (T1neu referenziert jetzt die konkrete AGB-Fassung; config/legal.php nur noch Fallback). (b) `rejection_categories`-DB: /richtlinien rendert aktive Kategorien live (Seeder K1–K6; deaktivieren statt loeschen). (c) T13: Melde-Zeile sichtbar unter jedem Artikel. (d) T10: Formulare /melden/rechtswidrig (DSA Art. 16 inkl. Wahrheitsversicherung) + /melden/kontaktdaten (DSGVO) mit URL-Feld und automatischem PM-Match; Vorgangsnummer M-{id}; unverzuegliche Eingangsbestaetigung + internes Postfach-Mail (auch im Per-PM-Flow); Admin-Queue zeigt Vorgangsnummer/URL/Name. (e) T12: Depublikations-Checkout (Panel auf der Kunden-PM-Seite mit T1neu-Checkbox + Doppelausweis, Stripe-Ad-hoc-Charge, Webhook depubliziert → Status archived + `depublished_at`, oeffentliche URL zeigt Tombstone mit 410). **Preis entschieden (O6, User 06.07.): 35 EUR netto** — Default 3500 in config/billing.php, per BILLING_DEPUBLICATION_PRICE_CENTS ueberschreibbar (0 deaktiviert). Go-Live: beide Seeder in WS-7-Checkliste.
|
||||
**06.07. — Spoke-Frontend-Audit umgesetzt (Fake raus, Newsletter zurueckgebaut, Boersendaten echt, EN-Lokalisierung):** Ergebnis des businessportal24-Frontend-Audits vollstaendig abgearbeitet: (a) **Fake-Inhalte entfernt** — Fake-Anzeigen (`focus-ad`, `feed-ad`, Inline-Ad + Studie-Box in release-detail) geloescht; alle Mock-/Fallback-Daten raus (focus-hero, feed, most-read, active-newsrooms, industry-spotlight, industry-index rendern jetzt nur noch mit echten Daten oder gar nicht); Legacy-Mock-Komponenten geloescht (`featured-releases`, `press-releases-grid`, `highlight-card`, `press-release-card`); tote Links/Buttons ersetzt oder entfernt (Newsroom-Link, Richtlinien-Link, Print statt Permalink, „Newsroom folgen" raus, RSS-Erwaehnung raus, ⌘K funktional). (b) **Newsletter zurueckgebaut** — `newsletter-strip` aus allen Spoke-Seiten + Newsletter-Box aus release-detail entfernt (Feature nachgelagert, s. unten; Backend/DOI-Hinweis in R6.1 bleibt). (c) **Boersendaten echt** — neuer `MarketDataService` (Yahoo-Finance-Quotes, `config/market.php`, Cache) + Artisan-Command `market:refresh` alle 15 Min. im Scheduler; `live-ticker` konsumiert nur noch echte Daten und blendet sich ohne Daten aus. (d) **EN-Lokalisierung** — alle UI-Texte der Kern-Seiten (Header/Footer, Home-Sektionen, release-detail, kategorie, suche, newsrooms, newsroom, Pagination) in `lang/de/web.php` + `lang/en/web.php` ausgelagert; `Carbon::setLocale('de')`-Hardcodes auf `app()->getLocale()` umgestellt. **Langform-Seiten (veroeffentlichen, faq, hilfe, api) bleiben vorerst deutsch** — hreflang="en" dort entfernt; EN-Uebersetzung nachgelagert.
|
||||
**06.07. — Zwei Legacy-Befunde des Users gefixt:** (1) **Buchungs-Gate-Bug:** `grandfatheredNow` verlangte ein gesetztes `grandfathered_until` — das ist aber das explizite Legacy-Vertragsende und im Import-Regelfall NULL; dadurch hatte praktisch kein Bestandskunde eine aktive Zahlungsoption („Buchung erforderlich"). Jetzt deckelt `grandfathered_until` nur, wenn gesetzt; sonst zaehlt die laufende, vom MAN-Lauf fortgerollte Periode (`current_period_end`). Lokal: 15/22 Vereinbarungen wieder aktiv, Limits/unbegrenztes Kontingent greifen. (2) **Legacy-API-Uebernahme (User-Entscheidung, ersetzt „OUT" aus der Scope-Doku):** `legacy:import-api-keys` uebernimmt die api_keys der Altportale als Sanctum-Tokens mit identischem Secret (nur berechtigte Nutzer; Dry-Run lokal: 406 Keys); die Auth-Middleware uebersetzt X-ApiKey/api_key fuer gueltige Keys in Bearer (unbekannte weiter 410+Hinweis); die dokumentierten /service/…-Pfade sind Aliasse auf die v1-Controller (nicht uebernommene Alt-Endpoints antworten 410 mit Hinweis). Go-Live-Kette: Import laeuft NACH dem Grandfathering (WS-7 ergaenzt). Ausserdem R6.1-Vorpruefung: /thumbnails-Redirect nachgeruestet (fehlte trotz „ANGEPASST"-Vermerk), QA-Checkliste `dev/release/qa-checkliste.md` erstellt.
|
||||
**06.07. — Zwei Legacy-Befunde des Users gefixt:** (1) **Buchungs-Gate-Bug:** `grandfatheredNow` verlangte ein gesetztes `grandfathered_until` — das ist aber das explizite Legacy-Vertragsende und im Import-Regelfall NULL; dadurch hatte praktisch kein Bestandskunde eine aktive Zahlungsoption („Buchung erforderlich"). Jetzt deckelt `grandfathered_until` nur, wenn gesetzt; sonst zaehlt die laufende, vom MAN-Lauf fortgerollte Periode (`current_period_end`). Lokal: 15/22 Vereinbarungen wieder aktiv, Limits/unbegrenztes Kontingent greifen. (2) **Legacy-API-Uebernahme (User-Entscheidung, ersetzt „OUT" aus der Scope-Doku):** `legacy:import-api-keys` uebernimmt die api_keys der Altportale als Sanctum-Tokens mit identischem Secret (nur berechtigte Nutzer; Dry-Run lokal: 406 Keys); die Auth-Middleware uebersetzt `X-ApiKey`/`X-Api-Key` fuer gueltige Keys in Bearer. **Härtung 14.07.:** Query-/Body-Parameter `api_key` werden nicht mehr akzeptiert, damit Secrets nicht in Logs/Referrern landen. Unbekannte Header-Keys erhalten weiter 410+Hinweis. Die dokumentierten /service/…-Pfade sind Aliasse auf die v1-Controller (nicht uebernommene Alt-Endpoints antworten 410 mit Hinweis). Go-Live-Kette: Import laeuft NACH dem Grandfathering (WS-7 ergaenzt). Ausserdem R6.1-Vorpruefung: /thumbnails-Redirect nachgeruestet (fehlte trotz „ANGEPASST"-Vermerk), QA-Checkliste `dev/release/qa-checkliste.md` erstellt.
|
||||
**06.07. — Bestandskunden end-to-end verifiziert + Adress-Luecke geschlossen (Suite: 993 gruen):** Nach dem Buchungs-Gate-Fix zeigte sich die naechste Luecke der Kette: Der MAN-Rechnungslauf ueberspringt Vereinbarungen ohne Rechnungsadresse — und der Grandfather-Import nahm Adressen nie mit, weil die Altportale sie nur als Freitext-Blob fuehren. Neu: `legacy:import-billing-addresses` (LegacyAddressParser strukturiert die Blobs best-effort, raet nie; Report der manuell nachzupflegenden Faelle; nie ueberschreibend; CY in config/countries.php ergaenzt). Lokal: 11/21 Bestandskunden mit Adresse, MAN-Lauf rechnet ab (Zypern-Rechnungen exakt wie Legacy-Brutto, kein Preissprung), 9 manuell zu klaeren (7 leere Alt-Adressen). API-Key-Import echt gelaufen (406 Tokens), connektar-Key end-to-end gegen /service und /api/v1 verifiziert (200). **Git-Hygiene vorgezogen:** Users Frontend-Umbau committet (eigener Commit), Produktiv-SQL-Dumps enttrackt + .gitignore (User-Entscheidung: ohne History-Bereinigung, Repo bleibt privat; Dumps liegen weiter lokal fuer R6.3).
|
||||
**07.07. — QA-Abschnitt A abgenommen + Billing/USt-Haerten + Tarifwechsel/Downgrades:** QA-Checkliste Abschnitt A (Login & Konto) komplett manuell abgenommen; dabei 3 2FA-Befunde gefixt: 2FA-Einrichtung verlangt jetzt Code-Bestaetigung (vorher keine Login-Challenge), Recovery-Codes nur noch einmalig sichtbar, falscher Challenge-Code crasht nicht mehr (fehlende Route `two-factor.login`); E-Mail-Wechsel jetzt als Pending-Flow mit Bestaetigungslink an die neue Adresse. **Stripe-USt vervollstaendigt:** `automatic_tax` in der Checkout-Session aktiviert (USt fehlte vorher in der Session), STR-Rechnungsspiegelung liest USt aus `total_taxes` (Stripe-API liefert kein `tax`-Feld), Profil-Rechnungsadresse als Stripe-Rechnungsempfaenger (Wallet-Daten ueberschreiben sie nicht mehr); Rechnungsseite listet neue STR/MAN-Rechnungen (STR verlinkt ins Stripe-Portal), Archiv-Filter entfernt, Altsystem-Archiv nur noch fuer Bestandskunden. **Tarifwechsel im Buchungs-Modal:** Abo-Swap mit anteiliger Verrechnung; Downgrades ohne Gutschrift erst zum Periodenende, Kontingent-Sperre beim Wechsel. **UX-Runde:** Volltext-Suchindex (+ Fix doppelte Alpine-Instanz), PM-Vorschau im echten Portal-Design mit Portal-Switch, Ablehnungsbegruendung direkt im Editor, Einreichen-Modal-Panelstruktur + fixierter Kontingent-Badge, Add-ons-Modal statt Toast bei fehlendem Guthaben, Depublikations-Sektion ans PM-Seitenende + Direktlinks je Portal, `single_purchases`-ENUM um `depublication` erweitert, abgebrochener Depublikations-Checkout blockiert nicht mehr.
|
||||
**08.07. — Agentur-Attribution + Depublikations-Kauf + eVatR-USt-ID-Pruefung:** Agentur-Attribution neu geloest — Uebermittler kommt aus neuem Profilfeld `users.agency_name` statt eigener Agentur-Firma; PM-Detailseite zeigt Urheber/Uebermittler-Zeile; `legacy:migrate-agency-names` uebertraegt die Alt-Agenturnamen; `companies:retire-agency-type` schafft den Firmen-Typ „Agentur" ab; Firmen-Typ-Auswahl aus der Profil-UI entfernt (Plan abgehakt, Abnahme 08.07. dokumentiert). **Depublikation end-to-end:** Kauf wird als eingeloest markiert, erzeugt Rechnung + Bestaetigungs-Mail; Buchungsseiten-Verlauf zeigt den Kauftyp, Erfolgsmeldungen gruen. **USt-ID-Pruefung (neu):** vor jeder Buchung per eVatR (EU-VIES) gegengeprueft, Stripe-Tax-IDs abgeglichen; eigene USt-IdNr. aus `BILLING_OWN_VAT_ID`. Deploy-Runbook §3a ergaenzt: FULLTEXT-Migrations-Wartungsfenster fuer `press_releases` (bei importiertem Bestand faellt jede Spaltenaenderung auf ALGORITHM=COPY = Stunden; Ablauf Indizes droppen -> migrieren -> nacheinander neu bauen = ~6 Min).
|
||||
|
|
@ -34,6 +34,8 @@ R2.2+R2.3 erledigt: Hub-Landing komplett ueberarbeitet (datengetriebenes Tarif-G
|
|||
|
||||
**13.07. — Boost-Platzierung entschieden + umgesetzt (Suite: 1222 gruen):** User-Entscheidung: harte Slot-Grenzen (5 Startseite, 3 je Branchenseite = exakt die sichtbaren Plaetze; Hero-Slider damit vorerst unnoetig, bleibt Ausbaustufe) + zweite Boost-Stufe **Kategorie-Boost** (nur Branchenseite, 50 % aufgerundet: 6/10/18 Credits; Startseiten-Boost unveraendert 12/20/35 inkl. Branchenseite). Umsetzung: `boosts.placement` + `BoostPlacement`-Enum, `credits.boost_category`/`boost_slots`, `BoostService::slotAvailability()` (je Portal x Sprache, Kategorie zusaetzlich je Rubrik; Verlaengerung haelt ihren Slot; ausgebucht -> Exception mit fruehestem freien Termin), Startseite zeigt nur Home-Boosts, Branchenseite Kategorie-Boosts mit Vorrang, Kategorie-Boost invalidiert nur Kategorie-Caches, Add-on-Panel mit zwei Angeboten + „Derzeit ausgebucht — frei ab"-Anzeige, Preise-/Buchungsseite + Lang-Keys (de/en) inkl. Slot-Hinweis in der Leistungsbeschreibungs-Note (**[EXTERN]: in anwaltliche Freigabe aufnehmen**). Details in `dev/release/boost-platzierung-offen.md`.
|
||||
|
||||
**14.07. — Optimierungs-Audit vollständig priorisiert umgesetzt:** Alle kritischen/hohen Befunde sowie M1–M8 und M11–M13 aus `dev/optimize/optimize-14-07-2026.md` sind code-seitig geschlossen: granulare Admin-Autorisierung inkl. Livewire-Methoden-Guards und admin-only Legacy-KI-Schalter; Quota-/Publish-Transaktionen mit Row-Locks; Slug-Unique-Retry; API-/Legacy-HTML-Sanitizing; serverseitige Attachment-MIME-Prüfung; Newsletter-Ownership/Confirmation; Legacy-Keys nur noch im Header; Deferred-View-Tracking; Suche-/Newsroom-Caches. Neue PM-Medien liegen privat und werden abhängig von öffentlicher Sichtbarkeit bzw. Policy ausgeliefert. **Deploy-Pflicht:** `php artisan db:seed --class=RolesAndPermissionsSeeder` und einmalig `php artisan press-releases:privatize-media`. **[EXTERN/DRINGEND]:** OpenAI-, Google- und Mail-Secrets beim Anbieter rotieren; Produktiv-Env mit `APP_ENV=production`, `APP_DEBUG=false`, `SESSION_SECURE_COOKIE=true`.
|
||||
|
||||
**Wiedereinstieg / naechste Schritte:**
|
||||
1. ~~Klaerungsliste Rechtstexte~~ **erledigt 06.07.**; ~~R3.1-Restbacklog~~ **erledigt 06.07.** (siehe oben).
|
||||
2. **[EXTERN]:** Postfach `info@pressekonto.com` anlegen (Haupt-Rechts-Mail, 06.07.); CONTACT_EMAIL_*-Support-Postfaecher je Brand; REPORT_EMAIL_* optional · R1.2 Stripe-Live · R1.4 Preisfreigabe · R5.4 Favicons · anwaltliche Freigabe AGB v0.6/Datenschutz v0.3. (~~Depublikations-Preis~~ entschieden: 35 EUR netto.)
|
||||
|
|
@ -150,7 +152,7 @@ R2.2+R2.3 erledigt: Hub-Landing komplett ueberarbeitet (datengetriebenes Tarif-G
|
|||
## Phase R6 — QA & Go-Live (teils extern/Ops)
|
||||
|
||||
- [~] **R6.1 Manuelle QA-Checkliste durcharbeiten** — **Vorprüfung + aktualisierte Checkliste erledigt 06.07.:** `dev/release/qa-checkliste.md` ersetzt §15 der Scope-Doku (jeder Punkt mit Beleg-Tests bzw. konkreten manuellen Schritten; Testzugänge, Reihenfolge lokal → Staging). **Befunde:** (1) /thumbnails-Redirect fehlte trotz „ANGEPASST"-Vermerk → gebaut (`LegacyMediaRedirectController` + Tests, 301 auf neue Storage-URLs für PM-Bilder/Logos). (2) Newsletter-DOI unvollständig → bewusst vertagt, kein Launch-Blocker (kein öffentliches Formular verlinkt), vor Newsletter-Aktivierung DOI-Flow bauen. **Offen:** manuelle Browser-Durchgänge (User) — Kategorieseite/Veröffentlichen-Landing erst nach Abschluss des laufenden Frontend-Umbaus abnehmen; Stripe-/Scheduler-/Mail-Punkte beim Staging-Rehearsal (R6.3).
|
||||
- [x] **R6.2 Security-Review** — Audit + Härtung erledigt 03.07. **Solide befunden:** CSRF (Stripe-Webhook via Signatur, keine Ausnahmen), Magic-Link-Krypto (64-Zeichen-Token, sha256-gehasht, atomare Einmalverwendung), HTMLPurifier-Sanitizing der PM-Texte, Raw-Queries durchgängig mit Bindings, IDOR-Schutz doppelt (Scope + Policy), BasicAuth timing-sicher, Session host-only korrekt fürs Hub-and-Spoke-Setup. **Gefixt:** Security-Header-Middleware (app-seitig), Logo-Upload im Company-Create über ImageService (vorher SVG/XSS möglich), Rate-Limits für Passwort-Reset/Registrierung/Consume-Routen, `EnsureUserIsCustomer` als persistente Livewire-Middleware. Tests: `SecurityHardeningTest`. **Offen/bewusst:** CSP separat einführen (Livewire/Alpine-Inline-Strategie nötig); Token-Abilities werden auf API-Routen nicht feingranular geprüft (ein Scope für alles — ok, solange keine eingeschränkten Tokens ausgegeben werden); Draft-PM-Anhänge nur per URL-Obscurity (dokumentierter MVP-Tradeoff); HSTS am Proxy setzen; **[USER-ENTSCHEIDUNG] Legacy-SQL-Dumps (`dev/migration 2026/sql/*.sql`, Produktivdaten!) sind im Git getrackt — enttracken + ggf. History bereinigen.**
|
||||
- [x] **R6.2 Security-Review** — Audit + Härtung erledigt 03.07., zweimal erweitert 14.07. **Solide befunden:** CSRF (Stripe-Webhook via Signatur, keine Ausnahmen), Magic-Link-Krypto (64-Zeichen-Token, sha256-gehasht, atomare Einmalverwendung), HTMLPurifier-Sanitizing der PM-Texte, Raw-Queries durchgängig mit Bindings, IDOR-Schutz doppelt (Scope + Policy), BasicAuth timing-sicher, Session host-only korrekt fürs Hub-and-Spoke-Setup. **Gefixt:** Security-Header-Middleware (app-seitig), Logo-Upload im Company-Create über ImageService (vorher SVG/XSS möglich), Rate-Limits für Passwort-Reset/Registrierung/Consume-Routen, `EnsureUserIsCustomer` als persistente Livewire-Middleware; PM-Medien liegen jetzt privat statt nur URL-obskur und werden kontrolliert ausgeliefert; Legacy-API-Key nur noch im Header und Legacy-Schreibpfade erzwingen Token-Abilities; Admin-Autorisierung, Impersonation-Token-Sperre, Billing-/Boost-/Review-/Quota-/Slug-Races, Sanitizing, MIME und Newsletter-Rebind laut Optimierungs-Audit geschlossen. Formular-Mails sind gequeued, externe HTTP-Aufrufe gehärtet. Tests siehe `dev/optimize/optimize-14-07-2026.md`. **Bewusste Restentscheidung:** KI-Autopublishing bleibt aktiv; Prompt-Inhalte sind gehärtet, das verbleibende Modellrisiko ist dokumentiert. **Offen/bewusst:** CSP separat einführen (Livewire/Alpine-Inline-Strategie nötig); HSTS am Proxy setzen; **[EXTERN]** Secret-Rotation und Produktiv-Env-Härtung.
|
||||
- [ ] **R6.3 Staging-Import-Rehearsal** — Datenimport gegen produktiven Legacy-Snapshot auf Staging (mehrfach als der offene Vollständigkeitsnachweis referenziert, P6.10/P11.3). Achtung: `mysql_businessportal`-Verbindung lokal ist nur ein ~April-Snapshot — fürs Rehearsal echten aktuellen Dump ziehen. **[EXTERN: Server/Snapshot]**
|
||||
- [ ] **R6.4 Go-Live-Kette** — Go-Live-Mailing (Passwort-Reset) + API-Kunden-Mailing: Code fertig, **finale Texte + Freigabeliste [EXTERN]**; DNS-Cutover `pressekonto.de`; Legacy-System read-only während Final-Import; alte Symfony-Server abschalten. *(= P11.x)*
|
||||
- [ ] **R6.5 Git-Hygiene** — Branch `fix/zeitzone-europe-berlin` (**~180 Commits vor `main`**, Stand 09.07.) pushen und nach `main` mergen; danach Release-Tag. **Vorher:** den uncommitteten Arbeitsbaum-WIP (Kontakt-Nachrichten, Validierungs-Feedback, Page-Cache, Company-Portal-Restriction, Lang-Auslagerungen; s. Stand-Block) in fokussierte Commits zerlegen. Die drei `user_filter_presets`-Migrations zusammenführen (rev-5, „Hygiene vor Go-Live") optional davor.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue