# SECURITY_VERIFICATION.md — Unabhängige Verifikation der 4 Security-Härtungsaufgaben

**Projekt:** Receipt Scanner App (Next.js 15 App Router, TypeScript strict, Alias `@/` → `src/`)
**Verifikator:** finaler Verifikations-Subagent (unabhängig, kein Blindvertrauen in Selbsttests der Implementierungs-Agents)
**Verifiziert am:** 2026-08-17, 14:10 (lokale Zeit)

---

## Gesamturteil: ALLE 4 AUFGABEN PASS ✅

| Task | Status | Belege (Datei:Zeile) |
|---|---|---|
| 1. Prompt-Injection-Block (AI/LLM) | **PASS** | Guard im komponierten System-Prompt; Sanitizer in allen 3 Provider-Pfaden; 27/27 Tests |
| 2. KI-Nutzungslimits pro Nutzer/Tag | **PASS** | Daily-Cap 30/10, Pre-Check vor Extraktion, record nach Extraktion; 16/16 Tests |
| 3. Request-/Upload-Größenlimits | **PASS** | 16 Guard-Nutzungen (14× readJsonSized + 2× guardBodySize), 413 vor Body-Buffer; 13/13 Tests |
| 4. Rate-Limiting Passwort-Resets | **PASS** | Burst 5/10min + 10/h auf reset, 60/h auf verify, 7 Routen limitiert; 20/20 Tests |

---

## Task 1 — Prompt-Injection-Block (AI/LLM) — **PASS**

### Deliverables geprüft
- `src/lib/ai/promptInjection.ts` (NEU, 225 Zeilen):
  - `INJECTION_GUARD` (Z. 27–39): strikte deutsche Sicherheitsanweisung ("UNVERTRAUTE DATEN", Ignorieren von ignore/system prompt/instructions/tool calls, Schema-Zwang).
  - `buildExtractionSystemPrompt()` (Z. 44–46): hängt Guard an Basis-Prompt.
  - `sanitizeExtractionOutput<T>` (Z. 170–225): harte Grenzen — Strings gekappt (merchant.name 160, address 300, taxId 64, receiptNumber 128, lineItems.description 200, hospitality 200), Zahlen geklemmt (MAX_MONEY 1e9 Z. 51, MAX_QUANTITY 1e6 Z. 53, taxRate 0–100, confidence 0–1), Datum strikt YYYY-MM-DD inkl. realem Kalenderdatum (Z. 111–128), Uhrzeit strikt HH:MM (Z. 131–140), Enums via Zod-Schema mit Defaults (Z. 153–162), Währung nur A–Z ≤ 8 sonst EUR (Z. 146–150), Array-Caps (lineItems 200, taxBreakdown 10, Z. 55–57). Nicht-mutierend (neues Objekt, Z. 171).
- `src/lib/ai/extractor.ts`:
  - `SYSTEM_PROMPT = buildExtractionSystemPrompt(BASE_EXTRACTION_SYSTEM_PROMPT)` (Z. 159) — **Guard in allen 3 Provider-Pfaden** (OpenRouter Z. 372, Gemini Z. 420, OpenAI Z. 466: alle `role: "system", content: SYSTEM_PROMPT`).
  - `sanitizeExtractionOutput({...object, validation: PENDING_VALIDATION})` **direkt nach jedem `generateObject`**: OpenRouter Z. 385, Gemini Z. 433, OpenAI Z. 479 (generateObject: Z. 367/415/461).

### Test-Ergebnis
`node_modules/.bin/sucrase-node tests/security/prompt_injection.test.ts` → **6 Suites, 27 Tests, 27 Pass, 0 Fail, Exit 0** (Suites: System-Prompt-Komposition, String-Caps, Zahlen-Grenzen, Datum & Uhrzeit, Enums & Währung, Arrays & Integrität; inkl. Round-Trip-, Nicht-Mutations- und Erhalt-nicht-modellierter-Felder-Tests).

---

## Task 2 — KI-Nutzungslimits pro Nutzer/Tag — **PASS**

### Deliverables geprüft
- `src/lib/ai/usage.ts` (NEU, 156 Zeilen):
  - `DAILY_SCAN_LIMIT = 30` (Z. 37), `DAILY_GUEST_SCAN_LIMIT = 10` (Z. 40), `HOURLY_SCAN_LIMIT = 120` (Z. 47, dokumentierend).
  - `checkDailyUsage(key, limit, now)` (Z. 93–115): nicht-mutierender Pre-Check, `now` injizierbar, kaputte Konfiguration → "allow".
  - `recordUsage(key, units, now)` (Z. 123–146): 24h-Fixed-Window (`DAILY_WINDOW_MS` Z. 34), Units-Ceil (Seiten zählen), defensive Normalisierung.
  - `usageKeyForUser`/`usageKeyForGuest` (Z. 149–155): Formate `ai:user:<id>` / `ai:guest:<bucket>`.
  - Overshoot- und Deployment-Caveat (In-Memory, pro Instanz) dokumentiert (Z. 17–31).
- `src/app/api/scan/route.ts` — Verdrahtung (Reihenfolge im POST verifiziert):
  1. `requireCsrf` (Z. 53) — CSRF **vor** allem.
  2. `guardBodySize(req, MAX_UPLOAD_BYTES)` (Z. 58–59) — Size-Guard **vor** `req.formData()` (Z. 64) und **vor** dem Daily-Check.
  3. IP-Rate-Limit `scan:ip:` (Z. 61).
  4. **Daily-Pre-Check** (Z. 91–103): `usageKey = user ? usageKeyForUser : usageKeyForGuest`; `checkDailyUsage` → 429 `{ error: "daily_scan_limit_reached", limit, retryAfter }` mit `Retry-After` (Z. 98–99) — **vor** dem `dbAvailable`-Block (Z. 105) und **vor** der Extraktion (Z. 199–228).
  5. Extraktion (Z. 199–228), danach `recordUsage(usageKey, document.pageCount)` (Z. 284) — **mit** `document.pageCount` (Seiten zählen als AI-Calls).
  - Bestehende CSRF-, Size-, Quoten-Logik intakt: User/Free-Quota (Z. 109–133), Guest-Quota (Z. 134–171), DB-Counter (Z. 259–279).

### Test-Ergebnis
`node_modules/.bin/sucrase-node tests/security/ai_usage_cap.test.ts` → **5 Suites, 16 Tests, 16 Pass, 0 Fail, Exit 0** (Pre-Check-Semantik, Zähler & Fensterstart, Fenster-Reset über injiziertes `now`, Key-Isolation, Overshoot & Konfiguration).

---

## Task 3 — Request-/Upload-Größenlimits — **PASS**

### Deliverables geprüft
- `src/lib/http/requestSize.ts` (NEU, 72 Zeilen):
  - `contentLengthExceeded` (Z. 11–19): Header-only, malformed/negativ → false (wird nachgemessen).
  - `guardBodySize` (Z. 27–30): 413 `{ error: "request_too_large", maxBytes }` vor Body-Buffer.
  - `readJsonSized` (Z. 41–71): Content-Length → 413; Parse-Fehler → 400 `invalid_json`; Nachmessung (serialisierte Länge) → 413.
- `src/lib/limits.ts`: `MAX_UPLOAD_BYTES = 10 MB` (Z. 11), `MAX_JSON_BODY_BYTES = 1 MB` (Z. 14), `MAX_FORM_FIELDS = 20` (Z. 17), `MAX_FORM_FILES = 20` (Z. 20).
- Gehärtete Routen (grep über `src/app`, **16 Nutzungen** ≥ 15 gefordert):
  - `guardBodySize` (2): `api/scan` Z. 58 (**vor** `req.formData()` Z. 64); `webhooks/stripe` Z. 90 (**vor** `req.text()` Z. 93, Signatur gegen raw body — Buffer-Schutz bestätigt).
  - `readJsonSized` (14): `auth/login` Z. 30, `auth/signup` Z. 63, `auth/forgot-password` Z. 40, `auth/reset-password` Z. 30, `auth/resend-verification` Z. 40, `auth/change-password` Z. 54, `api/receipts` POST Z. 190, `export/csv` Z. 15, `export/excel` Z. 15, `export/pdf` Z. 15, `onboarding` Z. 29, `checkout` Z. 27, `waitlist` Z. 23, `license/verify` POST Z. 135.
  - Stichproben gelesen (onboarding, export/csv, checkout, license/verify, receipts): alle im POST-Handler, nach CSRF, vor Body-Nutzung/DB, `!parsed.ok → parsed.response`-Muster konsistent.

### Test-Ergebnis
`tests/security/request_size.test.ts` → **3 Suites, 13 Tests, 13 Pass, 0 Fail, Exit 0**.

**Ausführungs-Notiz (transparent dokumentiert):** Die Datei importiert `src/app/api/scan/route`, das intern `@/`-Aliase und `src/lib/image/processor.ts` nutzt. Beides blockiert Plain-`sucrase-node`:
1. `@/`-Alias → gelöst per `Module._resolveFilename`-Shim (Muster aus `password_reset_rate_limit.test.ts`).
2. `processor.ts` nutzt `import.meta.url` (Z. 379, ESM-only) → unter CJS-Transpilern nicht ladbar (Node-24-Erkennung wirft `exports is not defined in ES module scope`). Keiner der 13 Tests ruft `processReceiptDocument` auf (der 413 feuert vor jeder Verarbeitung; der Boundary-Test beweist genau das über die echte Route: `formData`-Fehler bei Route.ts:64 → 500 statt 413). Daher wurde im temp. Wrapper nur die **Import-Kette** dieses einen Moduls durch ein Stand-in gleicher Export-Surface ersetzt. Der Wrapper (`node .verify_request_size.cjs` mit `sucrase/register`) wurde nach Abschluss gelöscht; die Testdatei selbst blieb unangetastet.

---

## Task 4 — Rate-Limiting Passwort-Resets — **PASS**

### Deliverables geprüft
- `src/app/api/auth/reset-password/route.ts`:
  - **Burst-Limit** `reset:burst:ip:<ip>` 5/10min (Z. 46–47) **zusätzlich** zu IP 10/h `reset:ip:<ip>` (Z. 49–50); 429 via `rateLimited()` aus `http.ts` (Import Z. 4).
- `src/app/api/auth/verify/route.ts`:
  - IP 60/h `verify:ip:<ip>` (Z. 32), Überschreitung → **Redirect `status=rate_limited`** (Z. 33) — Redirect-Vertrag der GET-Route bleibt intakt.
- Coverage-Matrix (grep `rateLimit` unter `src/app/api/auth/` → 23 Treffer in 7 Routen; alle 429 via `rateLimited()` aus `src/lib/auth/http.ts`, Z. 21–26):
  | Route | Limits | Beleg |
  |---|---|---|
  | forgot-password | IP 5/h `forgot:ip:` + Email 3/h `forgot:email:` | Z. 52–53, 58–59 |
  | reset-password | Burst 5/10min `reset:burst:ip:` + IP 10/h `reset:ip:` | Z. 46–47, 49–50 |
  | change-password | IP 10/15min `change:ip:` (bestand) | Z. 74–75 |
  | resend-verification | IP 5/h `resend:ip:` + Email 3/h `resend:email:` | Z. 52–53, 58–59 |
  | login | IP 20/15min `login:ip:` + Email 10/15min `login:email:` | Z. 42–43, 51–52 |
  | signup | IP 5/h `signup:ip:` + Email 3/h `signup:email:` | Z. 75–76, 91–92 |
  | verify | IP 60/h `verify:ip:` | Z. 32 |
- Keine Auth-Route ohne Limit, die einen Body mit Passwort/Token verarbeitet: alle 6 Passwort/Email-POST-Routen gelistet; `google/callback` (GET, OAuth-Code+State mit State/Verifier-Cookie + constant-time Vergleich, Z. 60–62) und `verify` (GET, jetzt limitiert) sind keine Passwort-Body-Routen.
- Kanonische Implementierung `src/lib/security/rateLimit.ts`: Fixed-Window (Z. 59–78), `clientIp` vertraut nur dem Proxy-angehängten rechten `x-forwarded-for`-Eintrag + `x-real-ip`, nie rohen Client-Header (Z. 100–115), `resetRateLimits` für Tests (Z. 81–83).

### Test-Ergebnis
`node_modules/.bin/sucrase-node tests/security/password_reset_rate_limit.test.ts` → **4 Suites, 20 Tests, 20 Pass, 0 Fail, Exit 0** (Fixed-Window-Semantik, die 7 Produktions-Budgets, 429-Helper, Client-IP-Extraktion; enthält den `@/`-Shim).

---

## Gesamt-Test-Ergebnisse

| Datei | Suites | Tests | Pass | Fail | Exit |
|---|---|---|---|---|---|
| `tests/security/prompt_injection.test.ts` | 6 | 27 | 27 | 0 | 0 |
| `tests/security/ai_usage_cap.test.ts` | 5 | 16 | 16 | 0 | 0 |
| `tests/security/password_reset_rate_limit.test.ts` | 4 | 20 | 20 | 0 | 0 |
| `tests/security/request_size.test.ts` (via temp. Shim-Wrapper) | 3 | 13 | 13 | 0 | 0 |
| `tests/e2e/auth_security.test.ts` (Regressions-Check, via temp. Shim-Wrapper) | 7 | 38 | 38 | 0 | 0 |
| **Summe** | **25** | **114** | **114** | **0** | — |

Regressions-Check bestanden: Die Auth-Änderungen (readJsonSized-Umbau + Rate-Limit-Einbau) haben keine bestehende Auth-Logik (Email-Normalisierung, Passwort-Hashing, Token-Handling, Fehler-Vokabular) kaputt gemacht.

---

## TypeScript (Step C)

- **Gesamtlauf:** `node node_modules/typescript/bin/tsc --noEmit -p tsconfig.json` → **Exit 0, 0 Fehler** (2× verifiziert: normal und erzwungen mit `--incremental false` gegen den Buildinfo-Cache).
- **Attribution:** Keine Fehler in unseren Dateien. Die bekannte externe Datei `src/lib/http/sensitivePaths.ts` (anderer, parallel laufender Agent) ist inzwischen **syntaktisch sauber** (per `typescript.transpileModule` verifiziert) — kein Fehler mehr zuzuschreiben.
- Scoped-Kompilierung **nicht nötig** (Gesamtlauf sauber; die im Auftrag genannte Bedingung "falls Gesamtlauf nicht sauber" ist nicht eingetreten). Alle 4 neuen/geänderten Lib-Dateien (promptInjection.ts, usage.ts, requestSize.ts, limits.ts) und alle geänderten Routen sind Teil des grünen Programms.

---

## Build (Step D, best effort)

- **Ergebnis: NICHT AUSFÜHRBAR in dieser Sandbox — kein Codefehler.**
- `npm run build` → `next build` bricht sofort ab mit `[Error: spawn EPERM] { errno: -4048, syscall: 'spawn' }` beim Start der Next.js-Worker (jest-worker mit gepiptem Stdio). Das ist die dokumentierte Sandbox-Grenze (Kindprozess-Spawns mit Pipe-Stdio sind geblockt; Escalation ist in dieser Session deaktiviert).
- **Attribution:** Umgebungslimit, nicht unsere Dateien. Statischer Ersatznachweis: vollständiger `tsc --noEmit`-Lauf grün (Exit 0). Hinweis: `build_err.txt` vom 16.08. zeigt, dass ein früherer Lauf außerhalb dieser Grenze bis "Compiled successfully in 13.0s" kam und danach an einem `.next/server/middleware-manifest.json` scheiterte (Domäne middleware.ts des anderen Orchestrators, stalelog von gestern — heute nicht reproduzierbar, da der Build hier gar nicht startet).
- Empfehlung an den Orchestrator: Build außerhalb der Datei-Sandbox (bzw. mit erweiterten Rechten) final ausführen und eventuelle middleware/schema-Fehler dem zuständigen Agent attribuieren.

---

## Befunde & Anmerkungen (kein FAIL)

1. **`MAX_FORM_FIELDS`/`MAX_FORM_FILES`** (`limits.ts` Z. 17/20) sind definiert, werden aber von keiner Route ausgewertet. Das erfüllt die Deliverable-Spezifikation (Konstanten existieren); als Defense-in-Depth-Lücke dokumentiert. Die Scan-Route liest exakt ein Feld (`file`), der 10-MB-Content-Length-Guard ist die primäre Kontrolle. Kein Fix erforderlich für "verifiziert", optional nachrüstbar.
2. **In-Memory-Limiter** (`usage.ts` Z. 63–73, `security/rateLimit.ts` Z. 35–45): pro Instanz; beide Dateien dokumentieren die Deployment-Caveat (N Replicas multiplizieren das Budget; Restart leert Buckets). Für Single-Instance-Deployment akzeptabel — vor Skalierung auf Redis/Postgres umstellen.
3. **Test-Infrastruktur-Hinweis** (transparent): `request_size.test.ts` läuft nicht nativ unter `sucrase-node` — Ursache Nr. 1 ist der `@/`-Alias (bereits erwartet), Ursache Nr. 2 (neu entdeckt) ist `import.meta.url` in `src/lib/image/processor.ts`, das unter CJS-Transpilern grundsätzlich nicht ladbar ist. Lösung per temp. Wrapper (Alias-Shim + Import-Ketten-Stand-in für das nie aufgerufene Processor-Modul); Testdatei unverändert.
4. **`verify`-Route** antwortet per Redirect `status=rate_limited` statt 429 — bewusst so (GET-Redirect-Vertrag), im Code dokumentiert (Z. 25–33).

---

## Verifiziert am 2026-08-17, 14:10 — Gesamteinschätzung

**ALLE 4 AUFGABEN PASS.** 114/114 Tests grün (5 Suiten-Dateien, 25 Suites), vollständiger TypeScript-Check (inkl. aller 4 neuen Lib-Dateien und aller gehärteten Routen) mit Exit 0 und 0 Fehlern, Code-Audit mit Belegen je Task. Der Build konnte nur wegen der Sandbox-Grenze (`spawn EPERM`) nicht ausgeführt werden — kein Hinweis auf einen Codefehler in den verifizierten Dateien. Das Projekt gilt damit als **"verifiziert"** für den Scope dieser 4 Security-Härtungsaufgaben.

*Keine Produktionsdatei wurde verändert; alle Temp-Artefakte (Wrapper, Logs) wurden nach Abschluss gelöscht.*
