# Security-Hardening kotsch.tech — Design / Spec

**Datum:** 2026-06-21
**Branch:** `feat/wissen-subdomain` (auf User-Wunsch; vermischt mit laufender KARC-/Wissen-Arbeit)
**Grundlage:** 100-Agenten-Sicherheitsaudit (354 Agenten, 86 verifizierte Funde). Scope: **alle Tiers**.
**Test-Strategie:** TDD — pro Fix erst ein fehlschlagender Test, dann der Fix. Lauf via `composer test`
(`config:clear` davor, `config:cache` danach wiederherstellen — Projekt-Konvention). Pro Phase ein Commit.
**Secrets/Server:** Code-/Config-Fixes vollständig + `.env`-Anpassungen, wo möglich; echte Secret-*Werte*
und Server-Operationen in der manuellen Checkliste am Ende.

---

## Designentscheidung: CSP
Konservative CSP (`object-src 'none'; base-uri 'self'; frame-ancestors 'self'`) — bricht inline-Skripte,
AdSense und JSON-LD **nicht**. Volle nonce-basierte `script-src`-CSP ist **nicht im Scope** (über 228 Views
+ AdSense, blind nicht sicher testbar → eigener Folgelauf mit Seiten-für-Seiten-Verifikation).

---

## Phase 1 — Critical + High

### P1-1 [Critical] PayPal-Token an Bestellung binden + Betrag/Währung verifizieren
- **Datei:** `app/Http/Controllers/ShopPayPalController.php` (`return()` + `webhook()`)
- **Ist:** `return()` erfasst `?token=` und ruft `captureOrder()` ohne Abgleich gegen `$order->paypal_order_id`;
  Betrag wird nie geprüft. `paypal_order_id` ist beim Checkout bereits gespeichert
  (`ShopCheckoutController::store()` Zeile 165).
- **Fix:**
  1. In `return()`: `abort`/Redirect, wenn `$order->paypal_order_id !== $paypalOrderId`.
  2. Neuer Helfer in `PayPalClient`: `amountMatches($capture, $order): bool` — vergleicht
     `purchase_units.0.payments.captures.0.amount.{value,currency_code}` gegen `total_cents`/`currency`.
  3. Auslieferung (`markPaidAndDeliver`) in **beiden** Pfaden (return + webhook) nur bei Betrags-Match.
- **Test:** `tests/Feature/Shop/PayPalSecurityTest.php` — PayPalClient gemockt:
  Mismatch-Token → keine Auslieferung; Betrags-Mismatch → keine Auslieferung; korrekt → Auslieferung.

### P1-2 [High] IDOR auf Bestell-Erfolgsseite
- **Datei:** `app/Http/Controllers/ShopCheckoutController.php` `success()` (Zeile 180-190)
- **Ist:** `firstOrFail()` ohne `ownsOrder()`; zeigt PII + signierte Download-Links. `ownsOrder()`-Helfer
  existiert; `store()` setzt `shop_orders` in die Session vor dem PayPal-Handoff (PayPal-Käufer behalten Eigentum).
- **Fix:** PII + Download-Links nur rendern, wenn `ownsOrder()`; Nicht-Besitzer sehen minimale Bestätigung
  (Bestellnummer + Status), keine PII/keine Links. Route `checkout.success` zusätzlich `throttle:20,1`.
- **Test:** Besitzer-Session sieht PII/Links; Fremdsession nicht.

### P1-3 [High] Upload-RCE über Client-Dateiendung
- **Datei:** `app/Http/Controllers/Admin/ShopProductController.php` `storeImage()` (Zeile 110-111)
- **Ist:** Endung aus `getClientOriginalExtension()` (clientkontrolliert) → `shell.php` als `.php` in `public/uploads/shop/`.
- **Fix:** Endung aus erkanntem MIME (`$file->extension()`/feste Whitelist jpg|jpeg|png|webp); bei Mismatch ablehnen.
  Zusätzlich `public/uploads/.htaccess` committen, das PHP-Ausführung deaktiviert
  (`php_flag engine off` + `RemoveHandler`/`RemoveType` + `SetHandler default-handler`).
- **Test:** Upload eines gültigen Bildes mit `.php`-Name → gespeicherter Pfad endet auf Bild-Endung; `.htaccess` existiert.

### P1-4 [High] KARC-Demo CPU-/Speicher-DoS
- **Dateien:** `app/Http/Controllers/KarcController.php` `compare()`, `scripts/karc/compare.py`
- **Fix:**
  1. `Cache::lock('karc-compare', 70)` mit `->get()` — bei belegtem Lock 429 (globale Concurrency-Grenze).
  2. Validierung: `dimensions:max_width=8000,max_height=8000` zusätzlich zu mime/size.
  3. `compare.py`: `Image.MAX_IMAGE_PIXELS` hart setzen, `warnings.simplefilter('error', Image.DecompressionBombWarning)`.
  4. Process-Timeout 60→30 s.
- **Test:** Validierung lehnt Überdimension ab; bei gehaltenem Lock → 429.

---

## Phase 2 — Medium

### P2-1 trustProxies einschränken
- `bootstrap/app.php:25` `at: '*'` → `at: ['127.0.0.1', '::1']`. Stellt alle IP-Throttles wieder her.
- **Test:** Request mit gefälschtem `X-Forwarded-For` von untrusted Remote → `request()->ip()` ≠ Spoof.

### P2-2 Admin-Passwort härten
- Neue `app/Support/AdminCredentials.php`: `check(string $email, string $password): bool` —
  leerer konfigurierter Wert ⇒ immer false (gesperrt, wie API-Token); unterstützt bcrypt
  (`SHOP_ADMIN_PASSWORD_HASH`) via `password_verify`, sonst constant-time Plaintext-Vergleich.
- Verwendung in `AdminAuthController::submitLogin()` (Zeile 92-96) und `Admin/ShopDashboardController` Login.
- `config/shop.php:64` Default `'kotsch-shop-admin'` → `''`.
- **Test:** leeres Passwort-Config → Login verweigert; korrektes → OTP-Schritt.

### P2-3 Open Redirect /go
- `routes/web.php:426-432` + `resources/views/redirect.blade.php`: Auto-Redirect (10 s JS/meta) entfernen,
  manueller Klick mit `rel="noopener noreferrer"`; optionale `config('redirect.allowlist')` überspringt die
  Warnung für bekannte Hosts. Externe Ziele bleiben erlaubt (AdSense-Zweck).
- **Test:** `/go?url=https://example.com` rendert Interstitial ohne Auto-Redirect.

### P2-4 Stored-XSS in Admin-Report-Mails
- `app/Http/Controllers/Admin/AdminController.php` `sendReport()` (Zeile 332, 345, 358): alle DB-Werte
  `htmlspecialchars(..., ENT_QUOTES, 'UTF-8')`, `cnt` als `(int)`.
- **Test:** `Mail::fake()`, Bericht „topseiten" mit `<script>`-haltigem path → Mail-HTML enthält escapte Entität.

### P2-5 PayPal-Return: Throttle + kein Enumerations-Oracle
- Route `paypal.return` `throttle:10,1`; einheitlicher Redirect/Antwort für „nicht gefunden" vs „Capture fehlgeschlagen".

### P2-6 PDF-Stream
- `PdfShareController::stream()`: `Storage::disk('local')->readStream()` + `StreamedResponse` statt `get()`.
- Routen `pdf.show`/`pdf.download` `throttle:30,1`.
- **Test:** Routen-Throttle vorhanden; Stream liefert 200 + korrekte Header.

---

## Phase 3 — Low + Info

- **P3-1 SecurityHeaders** (`app/Http/Middleware/SecurityHeaders.php`): konservative CSP, HSTS app-seitig
  (`Strict-Transport-Security: max-age=31536000; includeSubDomains`), Permissions-Policy erweitern
  (`payment=(), usb=(), display-capture=(), clipboard-read=(), bluetooth=(), midi=()`). Test: Header vorhanden.
- **P3-2 SiteMonitor TLS** (`app/Support/SiteMonitor.php`): `check()` mit `VERIFYPEER=true/VERIFYHOST=2`;
  nur haehner-Origin (IP/self-signed) bekommt eine eigene Ausnahme. Test: `check('https://kotsch.tech')` verifiziert.
- **P3-3 Hardcoded DB-Creds** (`AdminController.php:42,341,354`): benannte Connections in `config/database.php`
  (`arthur_mysql`, `toolaro_sqlite`) aus env; `DB::connection(...)` statt inline-PDO.
- **P3-4 Absolute Admin-Session-Dauer** (`EnsureAdminAuth.php:32-38`): Sliding-Expiry auf `created_at + 30 Tage`
  hart deckeln (nicht endlos verlängern). Test: Session jenseits Absolut-Limit → Redirect Login.
- **P3-5 Throttle-Sweep** (`routes/web.php`): `admin reports/send` (5,1), `tester/mail` (5,10),
  `bestellungen/{order}/erinnerung` (10,1), Shop-API-Gruppe (60,1), `/news` & Sitemap mit `Cache::remember`.
- **P3-6 Kleinkram:**
  - Tester-User-Enumeration (`TesterController:17-27`) → generische Meldung.
  - Captcha (`ContactSpamGuard:38-46`) bei Fehlversuch neu ausstellen.
  - `page_views`-Prune-Command (`app/Console/Commands/`) + Scheduler-Eintrag.
  - Cart-Add-Owner-Mail (`ShopCartController`) per-Session drosseln.
  - `Tester`-Model `$guarded`/`$fillable` ohne Trust-Felder.
  - JSON-LD-Views `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT`.
  - `resources/views/wissen/site.blade.php`: `<script>`/`on*`-Strip am geparsten Body (Defense-in-Depth).
  - `__audit_test.php` (Repo-Root) löschen.

---

## `.env`-Änderungen (im Repo, lokale Datei)
- `SESSION_SECURE_COOKIE=true`
- `SESSION_ENCRYPT=true`
- Doppelten `SMTP_*`-Block (Zeile 73-77) entfernen, **falls** nirgends referenziert (vorher `grep`).

## Config-Defaults (im Repo)
- `config/shop.php` `admin.password` Default → `''` (gesperrt bei fehlendem env-Wert).

---

## Manuelle Checkliste (nur User / Server-Operationen)
1. **Zoho-SMTP-Passwort rotieren** + neues `MAIL_PASSWORD` setzen (steht im Klartext in `.env`).
2. **Starkes `SHOP_ADMIN_PASSWORD`** setzen (ideal: `SHOP_ADMIN_PASSWORD_HASH` als bcrypt).
3. **PayPal-Live-Secrets** setzen/rotieren **vor** Umstellung von `PAYPAL_MODE=sandbox` auf live.
4. **`public/pay/_private/files/`** aus dem Webroot nach `storage/app/` verschieben + `SHOP_DIGITAL_FILES_PATH` anpassen (assistiert).
5. **TLS-Cert** für haehner-Origin provisionieren (dann SiteMonitor-Ausnahme entfernen).
6. **`AllowOverride`/`.htaccess`-Wirksamkeit** auf dem Live-Webserver verifizieren (Schutz von `_private` & `uploads`).
7. Optional: Cloudflare-`CF-Connecting-IP` als robusteste Client-IP-Quelle (statt loopback-trust) evaluieren.

## Nicht im Scope (bewusst → Folgelauf)
Volle `script-src`-CSP; `npm audit`/`composer audit`; News-DOMDocument-XXE-Tiefenprüfung;
`GenerateAreaImages`-Schreibpfad-Audit.
