Files
scan-receipts/SECURITY_VERIFICATION.md
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

151 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 2739): strikte deutsche Sicherheitsanweisung ("UNVERTRAUTE DATEN", Ignorieren von ignore/system prompt/instructions/tool calls, Schema-Zwang).
- `buildExtractionSystemPrompt()` (Z. 4446): hängt Guard an Basis-Prompt.
- `sanitizeExtractionOutput<T>` (Z. 170225): 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 0100, confidence 01), Datum strikt YYYY-MM-DD inkl. realem Kalenderdatum (Z. 111128), Uhrzeit strikt HH:MM (Z. 131140), Enums via Zod-Schema mit Defaults (Z. 153162), Währung nur AZ ≤ 8 sonst EUR (Z. 146150), Array-Caps (lineItems 200, taxBreakdown 10, Z. 5557). 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. 93115): nicht-mutierender Pre-Check, `now` injizierbar, kaputte Konfiguration → "allow".
- `recordUsage(key, units, now)` (Z. 123146): 24h-Fixed-Window (`DAILY_WINDOW_MS` Z. 34), Units-Ceil (Seiten zählen), defensive Normalisierung.
- `usageKeyForUser`/`usageKeyForGuest` (Z. 149155): Formate `ai:user:<id>` / `ai:guest:<bucket>`.
- Overshoot- und Deployment-Caveat (In-Memory, pro Instanz) dokumentiert (Z. 1731).
- `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. 5859) — 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. 91103): `usageKey = user ? usageKeyForUser : usageKeyForGuest`; `checkDailyUsage` → 429 `{ error: "daily_scan_limit_reached", limit, retryAfter }` mit `Retry-After` (Z. 9899) — **vor** dem `dbAvailable`-Block (Z. 105) und **vor** der Extraktion (Z. 199228).
5. Extraktion (Z. 199228), 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. 109133), Guest-Quota (Z. 134171), DB-Counter (Z. 259279).
### 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. 1119): Header-only, malformed/negativ → false (wird nachgemessen).
- `guardBodySize` (Z. 2730): 413 `{ error: "request_too_large", maxBytes }` vor Body-Buffer.
- `readJsonSized` (Z. 4171): 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. 4647) **zusätzlich** zu IP 10/h `reset:ip:<ip>` (Z. 4950); 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. 2126):
| Route | Limits | Beleg |
|---|---|---|
| forgot-password | IP 5/h `forgot:ip:` + Email 3/h `forgot:email:` | Z. 5253, 5859 |
| reset-password | Burst 5/10min `reset:burst:ip:` + IP 10/h `reset:ip:` | Z. 4647, 4950 |
| change-password | IP 10/15min `change:ip:` (bestand) | Z. 7475 |
| resend-verification | IP 5/h `resend:ip:` + Email 3/h `resend:email:` | Z. 5253, 5859 |
| login | IP 20/15min `login:ip:` + Email 10/15min `login:email:` | Z. 4243, 5152 |
| signup | IP 5/h `signup:ip:` + Email 3/h `signup:email:` | Z. 7576, 9192 |
| 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. 6062) und `verify` (GET, jetzt limitiert) sind keine Passwort-Body-Routen.
- Kanonische Implementierung `src/lib/security/rateLimit.ts`: Fixed-Window (Z. 5978), `clientIp` vertraut nur dem Proxy-angehängten rechten `x-forwarded-for`-Eintrag + `x-real-ip`, nie rohen Client-Header (Z. 100115), `resetRateLimits` für Tests (Z. 8183).
### 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. 6373, `security/rateLimit.ts` Z. 3545): 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. 2533).
---
## 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.*