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

14 KiB
Raw Permalink Blame History

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.ts6 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.ts5 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.ts3 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.ts4 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.jsonExit 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 buildnext 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.