# Security-Hardening Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Alle verifizierten Sicherheitsfunde des 100-Agenten-Audits beheben (Zahlungsbetrug, RCE, IDOR, DoS, Härtung), jeder Fix per Regressionstest abgesichert.

**Architecture:** Laravel 12 / PHP 8.2 Multi-Subdomain-Portal. Fixes in 3 committeten Phasen (Critical/High → Medium → Low/Info). Pro Fix: fehlschlagender Test → Fix → grün.

**Tech Stack:** Laravel 12, PHPUnit (`composer test`), SQLite-Testdaten, Symfony Process/cURL, Blade.

**Testlauf-Konvention:** IMMER `composer test` (führt `config:clear` aus). Danach `php artisan config:cache` wiederherstellen. Einzeltest: `php artisan test --filter=NAME`. Bekannte Alt-Fehler (i18n/Shop-Seeds) ignorieren — nur neue Security-Tests müssen grün sein, keine Regression in zuvor grünen Tests.

---

## File Structure

**Neu:**
- `app/Support/AdminCredentials.php` — zentrale Admin-Passwort-Prüfung (leer=gesperrt, bcrypt-Support).
- `public/uploads/.htaccess` — PHP-Ausführung im Upload-Ordner aus.
- `app/Console/Commands/PrunePageViews.php` — page_views-Aufräumung.
- Tests unter `tests/Feature/Security/` und `tests/Feature/Shop/`.

**Geändert (Kern):** `ShopPayPalController`, `ShopCheckoutController`, `Admin/ShopProductController`, `KarcController`,
`scripts/karc/compare.py`, `bootstrap/app.php`, `AdminAuthController`, `Admin/ShopDashboardController`,
`Admin/AdminController`, `EnsureAdminAuth`, `SecurityHeaders`, `SiteMonitor`, `PdfShareController`,
`Support/Shop/PayPalClient`, `routes/web.php`, `config/shop.php`, `config/database.php`, `.env`,
diverse Views (JSON-LD, redirect, wissen/site).

---

## Phase 1 — Critical + High

### Task 1: PayPal-Token-Bindung + Betragsprüfung (P1-1, Critical)

**Files:**
- Modify: `app/Http/Controllers/ShopPayPalController.php`
- Modify: `app/Support/Shop/PayPalClient.php` (neuer `amountMatches()`-Helfer)
- Test: `tests/Feature/Shop/PayPalSecurityTest.php`

- [ ] **Step 1: Failing test** — `tests/Feature/Shop/PayPalSecurityTest.php`: erstelle eine Order mit `paypal_order_id='PP-CORRECT'`, `total_cents=5000`. (a) `GET shop/paypal/return?order=KT...&token=PP-WRONG` → Order bleibt unbezahlt. (b) Capture mit falschem Betrag (gemockt) → unbezahlt. (c) korrekter Token+Betrag → `isPaid()` true. `PayPalClient` via `mock()`/Fake.
- [ ] **Step 2:** `php artisan test --filter=PayPalSecurity` → FAIL.
- [ ] **Step 3: Fix `return()`** — nach Order-Load:
  ```php
  if ($order->paypal_order_id && $order->paypal_order_id !== $paypalOrderId) {
      return redirect()->route('shop.cart.index')
          ->with('error', 'Die Zahlung konnte keiner Bestellung zugeordnet werden.');
  }
  $capture = PayPalClient::captureOrder($paypalOrderId);
  if (!$capture || ($capture['status'] ?? null) !== 'COMPLETED' || !PayPalClient::amountMatches($capture, $order)) {
      return redirect()->route('shop.checkout.create')
          ->with('error', 'Die PayPal-Zahlung wurde nicht abgeschlossen. Bitte versuche es erneut.');
  }
  ```
  In `webhook()` analog `amountMatches()` vor `markPaidAndDeliver`.
- [ ] **Step 4: `amountMatches()` in PayPalClient** — vergleicht `data_get($capture,'purchase_units.0.payments.captures.0.amount.value')` (auf Cent gerundet) gegen `$order->total_cents` und `...currency_code` gegen `$order->currency` (case-insensitive). Bei fehlenden Feldern: false.
- [ ] **Step 5:** `php artisan test --filter=PayPalSecurity` → PASS.
- [ ] **Step 6: Commit** (am Phasenende gesammelt).

### Task 2: IDOR Bestell-Erfolgsseite (P1-2, High)

**Files:**
- Modify: `app/Http/Controllers/ShopCheckoutController.php` (`success()`)
- Modify: `routes/web.php:323` (throttle)
- Test: `tests/Feature/Shop/OrderSuccessOwnershipTest.php`

- [ ] **Step 1: Failing test** — Order anlegen; ohne Session-Ownership `GET /shop/bestellung/{nr}/danke` → Antwort enthält **nicht** `customer_email`; mit `session(['shop_orders'=>[id]])` → enthält die E-Mail.
- [ ] **Step 2:** filter → FAIL.
- [ ] **Step 3: Fix** — `success()` ein `$owns = in_array($order->id, $request->session()->get('shop_orders', []), true);` berechnen, an View `'owns'=>$owns` übergeben; `downloadLinks` nur wenn `$owns && isPaid()`. In `resources/views/shop/success.blade.php` PII-Block + Links hinter `@if($owns)`.
- [ ] **Step 4:** Route `checkout.success` `->middleware('throttle:20,1')`.
- [ ] **Step 5:** filter → PASS.

### Task 3: Upload-RCE (P1-3, High)

**Files:**
- Modify: `app/Http/Controllers/Admin/ShopProductController.php` (`storeImage()`)
- Create: `public/uploads/.htaccess`
- Test: `tests/Feature/Security/UploadExtensionTest.php`

- [ ] **Step 1: Failing test** — `UploadedFile::fake()->image('shell.php.jpg')`… besser: erzeuge ein echtes Fake-Bild und setze Originalnamen `shell.php`. Direkt-Unit auf `storeImage` via Reflection ODER Controller-Test als Admin mit gefälschtem Namen; assert: gespeicherter `image_path` endet auf `.jpg|.jpeg|.png|.webp`, nicht `.php`.
- [ ] **Step 2:** FAIL.
- [ ] **Step 3: Fix** — Endung über Whitelist aus MIME:
  ```php
  $ext = $file->extension(); // aus erkanntem MIME-Type
  $allowed = ['jpg','jpeg','png','webp'];
  if (!in_array($ext, $allowed, true)) { $ext = 'jpg'; }
  $filename .= '.' . $ext;
  ```
- [ ] **Step 4: `public/uploads/.htaccess`** anlegen:
  ```apache
  php_flag engine off
  <IfModule mod_php.c>
    php_admin_flag engine off
  </IfModule>
  RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .phps
  RemoveType .php .phtml .php3 .php4 .php5 .php7 .phps
  AddType text/plain .php .phtml .phps
  ```
- [ ] **Step 5:** PASS.

### Task 4: KARC-DoS (P1-4, High)

**Files:**
- Modify: `app/Http/Controllers/KarcController.php` (`compare()`)
- Modify: `scripts/karc/compare.py`
- Test: `tests/Feature/Security/KarcDemoLimitsTest.php`

- [ ] **Step 1: Failing test** — Validierung: Upload mit zu großen Dimensionen → 422 (nutze `UploadedFile::fake()->image('big.png', 9000, 9000)`). Lock-belegt-Pfad → 429 (Cache::lock vorab im Test halten).
- [ ] **Step 2:** FAIL.
- [ ] **Step 3: Fix Controller** — Validierung um `'image' => [..., 'dimensions:max_width=8000,max_height=8000']` erweitern; Body in `Cache::lock('karc:compare', 35)->block(0, fn()=>…)`-Muster oder manuelles `$lock=Cache::lock('karc:compare',35); if(!$lock->get()) return 429;` mit `finally $lock->release()`. Process-Timeout 60→30.
- [ ] **Step 4: Fix compare.py** — oben nach `from PIL import Image`:
  ```python
  import warnings
  Image.MAX_IMAGE_PIXELS = 8000 * 8000
  warnings.simplefilter('error', Image.DecompressionBombWarning)
  ```
- [ ] **Step 5:** PASS.

### Phase-1-Commit
- [ ] `composer test` (Security-Tests grün, keine neue Regression) → `config:cache` wiederherstellen → Commit `fix(security): PayPal-Bindung, IDOR, Upload-RCE, KARC-DoS (P1)`.

---

## Phase 2 — Medium

### Task 5: trustProxies (P2-1)
- Modify `bootstrap/app.php:25`: `at: '*'` → `at: ['127.0.0.1', '::1']`.
- Test `tests/Feature/Security/TrustProxiesTest.php`: Request mit `X-Forwarded-For: 1.2.3.4` ohne trusted Remote → `request()->ip()` ≠ `1.2.3.4`. (Hinweis: TestCase setzt REMOTE_ADDR=127.0.0.1; ggf. via `Request::create` mit anderem REMOTE_ADDR prüfen.)

### Task 6: Admin-Passwort (P2-2)
- Create `app/Support/AdminCredentials.php`:
  ```php
  public static function check(string $email, string $password): bool {
      $expEmail = mb_strtolower((string) config('shop.admin.email', ''));
      $hash = (string) config('shop.admin.password_hash', '');
      $plain = (string) config('shop.admin.password', '');
      $emailOk = $expEmail !== '' && hash_equals($expEmail, mb_strtolower($email));
      if (!$emailOk) return false;
      if ($hash !== '') return password_verify($password, $hash);
      if ($plain === '') return false; // kein Default → gesperrt
      return hash_equals($plain, $password);
  }
  ```
- `config/shop.php`: `admin.password` Default → `''`; `admin.password_hash => env('SHOP_ADMIN_PASSWORD_HASH','')`.
- `AdminAuthController::submitLogin()` Zeile 92-96 → `AdminCredentials::check(...)`; ShopDashboardController-Login analog.
- Test `tests/Feature/Security/AdminCredentialsTest.php`: leeres Passwort-Config → check() false; korrektes Plain → true; bcrypt-Hash → true.

### Task 7: Open Redirect /go (P2-3)
- `routes/web.php:426-432` + `resources/views/redirect.blade.php`: Auto-Redirect (meta/JS-Timer) entfernen; manueller Link `rel="noopener noreferrer"`; optional `config('redirect.allowlist',[])`-Hosts überspringen die Warnung.
- Test: `GET /go?url=https://example.com/x` → 200, Body enthält Link, **kein** `http-equiv="refresh"` / kein `location.href=` Timer.

### Task 8: Stored-XSS Admin-Mails (P2-4)
- `Admin/AdminController.php` `sendReport()` Zeilen 332/345/358: `htmlspecialchars((string)$row->path, ENT_QUOTES, 'UTF-8')`, `(int)$row->cnt`.
- Test `tests/Feature/Security/AdminReportEscapingTest.php`: `Mail::fake()` nicht nötig — extrahiere Escaping; einfacher: page_views mit `path = '<script>x</script>'` seeden, Report rendern lassen (Mail::fake + assert HTML enthält `&lt;script&gt;`). Falls Mail-Body schwer prüfbar: kleine private→testbare Helper-Methode `escapeRow()` extrahieren und unit-testen.

### Task 9: PayPal-Return Throttle + Oracle (P2-5)
- `routes/web.php:330` `paypal.return` `->middleware('throttle:10,1')`; in `return()` einheitlicher Redirect für „order fehlt" und „capture fehlgeschlagen" (gleiche Zielroute+Message).
- Test: Route hat throttle (Route::getRoutes prüfen) — oder 429 nach 11 Requests.

### Task 10: PDF-Stream (P2-6)
- `PdfShareController::stream()`: `StreamedResponse` mit `Storage::disk('local')->readStream($share->path)`.
- `routes/web.php` `pdf.show` (448) + `pdf.download` (455) `->middleware('throttle:30,1')`.
- Test `tests/Feature/Security/PdfStreamTest.php`: show liefert 200 + `Content-Type: application/pdf`; Routen haben throttle.

### Phase-2-Commit
- [ ] `composer test` → `config:cache` → Commit `fix(security): trustProxies, Admin-PW, Open-Redirect, Mail-XSS, PayPal/PDF-Throttle (P2)`.

---

## Phase 3 — Low + Info

### Task 11: SecurityHeaders (P3-1)
- `SecurityHeaders.php` `$headers` ergänzen: `'Content-Security-Policy' => "object-src 'none'; base-uri 'self'; frame-ancestors 'self'"`, `'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains'`, Permissions-Policy erweitern um `payment=(), usb=(), display-capture=(), clipboard-read=(), bluetooth=(), midi=()`.
- Test `tests/Feature/Security/SecurityHeadersTest.php`: `GET /de` → alle vier Header gesetzt; CSP enthält `object-src 'none'`.

### Task 12: SiteMonitor TLS (P3-2)
- `SiteMonitor::check()`: `VERIFYPEER=true, VERIFYHOST=2`. Neuer privater `bool $insecure`-Pfad nur für haehner: in `haehnerHealth()` bleibt es deaktiviert (IP/self-signed), `check()` wird verifizierend. Falls `check()` auch für die haehner-IP genutzt wird: Host-Check `if (str_contains($url,'212.227.70.84')) { verify off }`.
- Test: schwer ohne Netz — Unit auf eine extrahierte `curlOptions(string $url): array`-Methode: für `https://kotsch.tech` enthält `CURLOPT_SSL_VERIFYPEER=>true`.

### Task 13: Hardcoded DB-Creds (P3-3)
- `config/database.php`: Connections `arthur_mysql` (mysql, host/db/user/pass aus env `ARTHUR_DB_*`) und `toolaro_sqlite` (sqlite, `database` aus env `TOOLARO_DB_PATH`).
- `Admin/AdminController.php:42,341,354`: inline-`new \PDO(...)` → `DB::connection('arthur_mysql')` bzw. `'toolaro_sqlite'`.
- `.env`: `ARTHUR_DB_HOST/DATABASE/USERNAME/PASSWORD`, `TOOLARO_DB_PATH` ergänzen (Werte wie bisher: localhost/arthurkotsch_online_db/root/'' bzw. der sqlite-Pfad).
- Test: `config('database.connections.arthur_mysql')` existiert; Smoke: `getArthurPageViews()` wirft nicht (try/catch bleibt).

### Task 14: Absolute Admin-Session-Dauer (P3-4)
- `EnsureAdminAuth.php`: Sliding nur, solange `created_at + 30d` nicht überschritten; danach Session löschen + Redirect Login. (admin_sessions hat `created_at`.)
- Test `tests/Feature/Security/AdminSessionMaxAgeTest.php`: Session mit `created_at` vor 31 Tagen → Zugriff auf Admin → Redirect login.

### Task 15: Throttle-Sweep + Caching (P3-5)
- `routes/web.php`: `admin reports/send` (358) `throttle:5,1`; `tester/mail` (367) `throttle:5,10`; `bestellungen/{order}/erinnerung` (386) `throttle:10,1`; Shop-API-Gruppe (`routes/api.php`) `throttle:60,1`.
- `/news`-Listing + Sitemap: `Cache::remember(...)` (TTL 3600/86400). Sitemap-Closure in `routes/web.php:60` umschließen.
- Test: Routenliste enthält throttle für die genannten; Sitemap zweimal abrufen → identisch (Cache).

### Task 16: Kleinkram (P3-6)
- `TesterController:17-27`: unique-email-Message generisch.
- `ContactSpamGuard:38-46`: bei falscher Antwort Captcha-Session-Key neu setzen.
- `app/Console/Commands/PrunePageViews.php` (neu): löscht `page_views` älter 90 Tage; Scheduler in `routes/console.php`.
- `ShopCartController`: Owner-Mail per-Session drosseln (Session-Flag `cart_notified_at`, max 1/5 min).
- `app/Models/Tester.php`: `$guarded`/`$fillable` ohne `is_active`/`confirmed_at`.
- JSON-LD-Views (`apple/show.blade.php`, `news/show.blade.php`, `shop/show.blade.php` u.a.): `json_encode(..., JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE)`.
- `resources/views/wissen/site.blade.php`: Body vor `{!! !!}` durch einen `<script>`/`on*`-Strip in `WissenController::parseSite()` (preg_replace) jagen.
- `__audit_test.php` (Repo-Root) löschen.
- Test `tests/Feature/Security/MiscHardeningTest.php`: Tester-Doppel-Mail → generische Meldung; `JSON_HEX_TAG` greift (apple/show enthält kein rohes `</script>` aus Daten).

### `.env` + Phase-3-Commit
- [ ] `.env`: `SESSION_SECURE_COOKIE=true`, `SESSION_ENCRYPT=true`; doppelten `SMTP_*`-Block entfernen (vorher `grep -rn 'SMTP_PASS\|SMTP_USER\|SMTP_HOST' app config` → nur wenn 0 Treffer).
- [ ] `composer test` → `config:cache` → Commit `fix(security): Header/CSP, TLS, DB-Creds, Sessions, Throttles, Kleinkram (P3)`.

---

## Self-Review-Ergebnis
- **Spec-Abdeckung:** Alle P1-1…P3-6 + .env/Config/Checkliste haben Tasks. ✓
- **Platzhalter:** Keine „TBD"; Code-Snippets je Fix vorhanden. ✓
- **Konsistenz:** `AdminCredentials::check()`, `PayPalClient::amountMatches()`, `ownsOrder`-Muster über Tasks hinweg konsistent benannt. ✓
- **Nicht im Scope** (Folgelauf): volle script-src-CSP, npm/composer audit, DOMDocument-XXE-Tiefe — bewusst ausgelassen.
