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>
54 KiB
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):
EnsureUserIsAdminprüft nur das Gateaccess-admin(app/Http/Middleware/EnsureUserIsAdmin.php:20).access-admin=canAccessAdmin(), daseditoreinschließt:
// 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
}
- Der Seeder gibt
editorbewusst nichtusers:manage/roles:manage(database/seeders/RolesAndPermissionsSeeder.php:34-35,47-55) — die Granularität ist gewollt. - Die Volt-Komponenten
admin/users/*undadmin/roles/*erzwingen sie aber nirgends.save()schreibt ungeprüft aus dem Formular:
// 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:
// 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-…— echtersk-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
// 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:
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:187deleteCompany(),:104update()admin/press-releases/index.blade.php:220,244,259publish()/reject()/archive();edit.blade.php:271,337publish()/deletePressRelease()admin/categories/edit.blade.php:134delete();admin/footer-codes/edit.blade.php:85delete();admin/contacts/index.blade.php:351deleteContactFromIndex();admin/payments/index.blade.php:40cancelBundle()
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
// app/Http/Controllers/Web/ContactController.php:40
'page_url' => ['nullable', 'string', 'max:500'], // ← keine URL-Regel
{{-- 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:
@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
// 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:
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)
// 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:
->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-kernelCVE-2026-45075 (HEAD-Request umgehtmethods:['GET']-Filter in#[IsGranted]/CSRF-Attributen). - HIGH
symfony/mimeCVE-2026-45067 (E-Mail-Header/SMTP-Injection via CRLF inAddress). - 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
// 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:
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 Spokesview_prefix='web',assets_dir='build/web'. - Token-basierte Themes:
theme-presseecho.css/theme-businessportal24.cssimportierenshared-styles.cssund 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-footerlesenconfig('app.theme')), zentralerWebPortalContext::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_descriptioninconfig/domains.phpvorhanden - L32 Portal:
WebPortalContext::portalFromHost()->value
Umsetzung (Priorität 1):
resources/views/web/home.blade.phpmit dem gemeinsamen Body anlegen.- Die 4 Stellen durch die o. g. Lookups ersetzen (Brand/Meta aus
config('app.theme'), Portal ausWebPortalContext). HomeController::__invoke(app/Http/Controllers/Web/HomeController.php:21-24) aufreturn view('web.home', $pageData->build(...))vereinfachen — Verzweigung löschen.presseecho.blade.php+businessportal24.blade.phplöschen.- Feature-Test: beide Hosts rendern
web.homemit 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):
- K1 —
users:manage/roles:manageauf allenadmin/users/*- undadmin/roles/*-Komponenten erzwingen;is_super_admin/Admin-Rolle nur für Super-Admins. + M8 (is_super_adminaus$fillable). - H2 — Zielrechte für destruktive/publizierende Admin-Aktionen definieren und erzwingen (Klärung Rollenmodell
editor). - K2 — OpenAI-, Google-, Mail-Secrets rotieren; auskommentierte Live-Credentials entfernen.
- H1 — Impersonation-Rangprüfung. H4 — Wallet-Top-up in Transaktion + Row-Lock (echtes Geld).
- H3 —
page_url-Validierung + Render-Guard (trivialer Fix, hoher Impact). - H5 —
trustHosts-Whitelist. M9 (SESSION_SECURE_COOKIE=true), I1 (APP_DEBUG=falseNicht-Dev) mitnehmen. - H6 —
composer updateauf gepatchte Versionen,composer auditgrü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:
editorerhält 403 aufadmin/users/admin/rolesund kannis_super_adminnicht setzen. - H1-Regressionstest: Impersonation eines Admins/Super-Admins wird abgelehnt.
- H4-Regressionstest: doppelte Webhook-Zustellung gutschreibt nur einmal.
- H3-Regressionstest:
javascript:-page_urlwird 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
hitsum exakt 1 + genau einPressReleaseEvent; 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/EnsureUserIsCustomeralsaddPersistentMiddlewareregistriert → 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 — keinjavascript:), Sanitization doppelt (Speichern + Rendern),renderedText()als einziger Sink; Legacy-Plaintexte()-escaped;LinkPolicyerzwingtrel. - Bild-Uploads: inhaltsbasierte MIME-Prüfung, GD-Neukodierung (entfernt Payloads), Extension aus MIME abgeleitet, UUID-Namen, kein SVG.
- SQL-Injection: keine gefunden;
whereRaw/selectRaw/orderByRawdurchweg 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 (mirrorPaidInvoicededupt aufstripe_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;forceFilldurchweg hartkodiert (Ausnahme:is_super_adminim$fillable, M8). - CSRF: einzige Ausnahme signierte RFC-8058-Abmeldung, Signatur in beiden Handlern geprüft;
verify-email/email-aendernsigned+throttle+auth mit Zusatz-Checks. - Fehlerbehandlung: keine leeren catch-Blöcke in
app/; kritische Pfade loggen. - Timezone:
config/app.phpEurope/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/Sitemapgecacht. - Ö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:
app/Models/User.php—is_super_adminaus$fillableentfernt (M8). Wird ab jetzt ausschließlich per autorisiertemforceFill()gesetzt.app/Providers/AppServiceProvider.php— zentralerGate::before-Super-Admin-Kurzschluss ergänzt. Damit greifencan('users:manage')/can('roles:manage')auch für Super-Admins ohne passende Spatie-Rolle (analog zu den Policy-before()-Methoden).- Berechtigungsprüfung
authorize('users:manage')inmount()und allen Schreibmethoden vonadmin/users(Indexusers.blade.php,users/create,users/edit,users/show);authorize('roles:manage')analog inadmin/roles(roles/index,roles/create,roles/edit). Damount()serverseitig läuft und Livewire-Tests wie HTTP-Requests dagegen prüfen, ist die Sperre sowohl beim initialen Aufruf als auch bei/livewire/updatewirksam. admin/users/edit+create—is_super_adminaus demupdate()/create()-Array entfernt und in einen Super-Admin-only-forceFill-Zweig verschoben: ein Admin mitusers:manage, der selbst kein Super-Admin ist, kann den Rang nicht (mehr) vergeben.app/Services/Import/UserImporter.php— Import setztis_super_adminjetzt perforceFill()(da nicht mehr mass-assignable), Verhalten unverändert.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:
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.resources/views/livewire/admin/contact-messages/show.blade.php— Render-Guard: Link nur beihttp(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:
database/seeders/RolesAndPermissionsSeeder.php— neue admin-only Permissions:companies:write,categories:manage,footer-codes:manage,billing:manage,compliance:manage(nuradminviasyncPermissions($permissions); Super-Admin ohnehin überGate::before).routes/admin.php— Route-Gruppen mitcan:-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 hatcompanies:read), Schreiben (create/edit/Kontakt-Formulare) →can:companies:write. Show-Route auf[0-9]+beschränkt (sonst fängt{id}das Segmentcreate).- Methoden-Guards (Defense-in-depth) via
$this->authorize(...):companies:writein Firmen-update/deleteCompany/saveund Kontakt-save/deleteContactFromIndex;press-releases:publishin PM-publish/reject/archive/deletePressReleaseundpress-releases:writein PM-save(für Editor unverändert, blockt künftige geringer privilegierte Rollen). 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:
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.HasUniqueSlug::saveWithUniqueSlugRetry()fängt konkurrierende Unique-Verletzungen ab und berechnet einen neuen Suffix. V1-, Legacy-, Admin- und Kunden-Persistenzpfade nutzen den Retry.- Legacy-PM-HTML wird vor Persistenz mit demselben Purifier wie V1/Web bereinigt.
- 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 diePressReleasePolicy.press-releases:privatize-mediamigriert vorhandene öffentliche Bestandsdateien. - Die Attachment-Storage-Schicht prüft nun zusätzlich den serverseitig ermittelten MIME-Type.
- Newsletter-Re-Subscribes erhalten den bestätigten Zustand; ein fremder API-User kann
user_idund Profildaten nicht übernehmen. - Legacy-Schlüssel werden nur noch als Bearer-,
X-ApiKey- oderX-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=RolesAndPermissionsSeederauf allen Umgebungen ausführen (neue Permissions). - M4-Deployment: einmalig
php artisan press-releases:privatize-mediaausführen, damit vorhandene PM-Medien vonpublicnachlocalverschoben werden. - Migration-Preflight: Vor
migrate --forceprüfen, obinvoices.stripe_invoice_idbereits 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.