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>
151 lines
14 KiB
Markdown
151 lines
14 KiB
Markdown
# 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.*
|