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>
14 KiB
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: allerole: "system", content: SYSTEM_PROMPT).sanitizeExtractionOutput({...object, validation: PENDING_VALIDATION})direkt nach jedemgenerateObject: 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,nowinjizierbar, kaputte Konfiguration → "allow".recordUsage(key, units, now)(Z. 123–146): 24h-Fixed-Window (DAILY_WINDOW_MSZ. 34), Units-Ceil (Seiten zählen), defensive Normalisierung.usageKeyForUser/usageKeyForGuest(Z. 149–155): Formateai: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):requireCsrf(Z. 53) — CSRF vor allem.guardBodySize(req, MAX_UPLOAD_BYTES)(Z. 58–59) — Size-Guard vorreq.formData()(Z. 64) und vor dem Daily-Check.- IP-Rate-Limit
scan:ip:(Z. 61). - Daily-Pre-Check (Z. 91–103):
usageKey = user ? usageKeyForUser : usageKeyForGuest;checkDailyUsage→ 429{ error: "daily_scan_limit_reached", limit, retryAfter }mitRetry-After(Z. 98–99) — vor demdbAvailable-Block (Z. 105) und vor der Extraktion (Z. 199–228). - Extraktion (Z. 199–228), danach
recordUsage(usageKey, document.pageCount)(Z. 284) — mitdocument.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 → 400invalid_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/scanZ. 58 (vorreq.formData()Z. 64);webhooks/stripeZ. 90 (vorreq.text()Z. 93, Signatur gegen raw body — Buffer-Schutz bestätigt).readJsonSized(14):auth/loginZ. 30,auth/signupZ. 63,auth/forgot-passwordZ. 40,auth/reset-passwordZ. 30,auth/resend-verificationZ. 40,auth/change-passwordZ. 54,api/receiptsPOST Z. 190,export/csvZ. 15,export/excelZ. 15,export/pdfZ. 15,onboardingZ. 29,checkoutZ. 27,waitlistZ. 23,license/verifyPOST 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:
@/-Alias → gelöst perModule._resolveFilename-Shim (Muster auspassword_reset_rate_limit.test.ts).processor.tsnutztimport.meta.url(Z. 379, ESM-only) → unter CJS-Transpilern nicht ladbar (Node-24-Erkennung wirftexports is not defined in ES module scope). Keiner der 13 Tests ruftprocessReceiptDocumentauf (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.cjsmitsucrase/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/hreset:ip:<ip>(Z. 49–50); 429 viarateLimited()aushttp.ts(Import Z. 4).
- Burst-Limit
src/app/api/auth/verify/route.ts:- IP 60/h
verify:ip:<ip>(Z. 32), Überschreitung → Redirectstatus=rate_limited(Z. 33) — Redirect-Vertrag der GET-Route bleibt intakt.
- IP 60/h
- Coverage-Matrix (grep
rateLimituntersrc/app/api/auth/→ 23 Treffer in 7 Routen; alle 429 viarateLimited()aussrc/lib/auth/http.ts, Z. 21–26):Route Limits Beleg forgot-password IP 5/h forgot:ip:+ Email 3/hforgot:email:Z. 52–53, 58–59 reset-password Burst 5/10min reset:burst:ip:+ IP 10/hreset: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/hresend:email:Z. 52–53, 58–59 login IP 20/15min login:ip:+ Email 10/15minlogin:email:Z. 42–43, 51–52 signup IP 5/h signup:ip:+ Email 3/hsignup: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) undverify(GET, jetzt limitiert) sind keine Passwort-Body-Routen. - Kanonische Implementierung
src/lib/security/rateLimit.ts: Fixed-Window (Z. 59–78),clientIpvertraut nur dem Proxy-angehängten rechtenx-forwarded-for-Eintrag +x-real-ip, nie rohen Client-Header (Z. 100–115),resetRateLimitsfü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 falsegegen 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 (pertypescript.transpileModuleverifiziert) — 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 buildbricht 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.txtvom 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.jsonscheiterte (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)
MAX_FORM_FIELDS/MAX_FORM_FILES(limits.tsZ. 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.- In-Memory-Limiter (
usage.tsZ. 63–73,security/rateLimit.tsZ. 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. - Test-Infrastruktur-Hinweis (transparent):
request_size.test.tsläuft nicht nativ untersucrase-node— Ursache Nr. 1 ist der@/-Alias (bereits erwartet), Ursache Nr. 2 (neu entdeckt) istimport.meta.urlinsrc/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. verify-Route antwortet per Redirectstatus=rate_limitedstatt 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.