#!/usr/bin/env node /** * Verification for the "Sanitize before storing" hardening: * - src/lib/ingest/sanitize.ts (string/number sanitizers + stored-receipt zod schema) * - src/lib/export/csvGenerator.ts (CSV formula-injection neutralizer) * - excel / pdf generators (values stay plain strings / literal text) * * Runs under plain Node ≥ 23.6 (native TypeScript type-stripping): * node scripts/verify_sanitize.mjs * * Exit code 0 = all assertions passed, 1 = at least one failed. */ import { strict as assert } from "node:assert"; import ExcelJS from "exceljs"; import { SANITIZE_LIMITS, sanitizeText, sanitizeMultilineText, sanitizeUrl, sanitizeCurrency, sanitizeReceiptBatch, sanitizeReceipt, StoredReceiptSchema, } from "../src/lib/ingest/sanitize.ts"; import { generateAccountingCsv, neutralizeFormulaPrefix, } from "../src/lib/export/csvGenerator.ts"; import { generateDualSheetExcel } from "../src/lib/export/excelGenerator.ts"; import { generateReceiptPdf } from "../src/lib/export/pdfGenerator.ts"; // --------------------------------------------------------------------------- // Tiny test runner // --------------------------------------------------------------------------- let passed = 0; let failed = 0; const failures = []; function test(name, fn) { try { fn(); passed++; console.log(` PASS ${name}`); } catch (err) { failed++; failures.push({ name, err }); console.error(` FAIL ${name}\n ${err && err.message ? err.message : err}`); } } // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- function validReceipt(overrides = {}) { return { id: "rcpt_test_123", imageHash: "abc123def456", merchant: { name: "REWE", address: "Hauptstr 1, 10115 Berlin", taxId: "DE123456789", confidence: 0.98, }, date: { isoDate: "2026-08-15", time: "10:30", confidence: 0.97 }, documentType: "KASSENBON", receiptNumber: "2026-0815-001", currency: "EUR", totalAmount: { value: 12.34, confidence: 0.99 }, netAmount: 10.36, tipAmount: null, taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }], lineItems: [ { description: "Apfel", quantity: 2, price: 12.34, unitPrice: 6.17, taxRate: 19 }, ], suggestedCategory: "Sonstiges", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null, issues: null, userConfirmed: null, }, rawText: "REWE\nApfel 2x 6,17\nSUMME 12,34", paymentMethod: "EC_KARTE", previewUrl: "https://storage.example/rcpt_test_123.jpg", originalFileName: "bon.jpg", fileSizeBytes: 12345, status: "ready", createdAt: "2026-08-15T10:31:00.000Z", updatedAt: "2026-08-15T10:31:00.000Z", ...overrides, }; } function maliciousReceipt() { return validReceipt({ merchant: { name: "", address: "", taxId: "javascript:alert(3)", confidence: 1, }, receiptNumber: '=HYPERLINK("http://evil.example","x")', suggestedCategory: "@SUM(1+1)", rawText: '\nZeile 2\nZeile 3\u0000\u0007', lineItems: [ { description: "+cmd|' /C calc'!A0", quantity: 1, price: 12.34, unitPrice: 12.34, taxRate: 19 }, { description: "", quantity: 2, price: 0.5, taxRate: null }, ], validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: true, reviewField: "merchant", reviewReason: "", issues: [{ field: "merchant", severity: "warning", message: "" }], userConfirmed: null, }, paymentMethod: "EC_KARTE", }); } /** Splits one quoted CSV line (; delimiter) into unquoted cells. */ function splitCsvLine(line) { const cells = []; let cur = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else { inQuotes = false; } } else { cur += ch; } } else if (ch === '"') { inQuotes = true; } else if (ch === ";") { cells.push(cur); cur = ""; } else { cur += ch; } } cells.push(cur); return cells; } // --------------------------------------------------------------------------- // 1. String sanitizer // --------------------------------------------------------------------------- console.log("\n[1] String sanitizer"); test("strips script tags from merchant name", () => { assert.equal(sanitizeText("", 200), "alert(1)"); }); test("strips img/svg onerror markup entirely (tag content included)", () => { // The whole tag — including payload text inside it — is removed. assert.equal(sanitizeText('', 200), ""); assert.equal(sanitizeText("", 200), ""); }); test("strips comments / doctype / CDATA / processing instructions", () => { assert.equal(sanitizeText("ab", 200), "ab"); assert.equal(sanitizeText("ab", 200), "ab"); assert.equal(sanitizeText("a x b", 200), "ab"); assert.equal(sanitizeText("ab", 200), "ab"); }); test("malformed double-angle markup cannot survive", () => { assert.equal(sanitizeText("<\nZeile 2\nZeile 3\u0000\u0007", 1000); assert.equal(out, "Zeile 2\nZeile 3"); assert.ok(!out.includes("<")); }); test("multiline OCR caps at 50k", () => { assert.equal(sanitizeMultilineText("y".repeat(100000), 50000).length, 50000); }); test("sanitizeUrl drops dangerous schemes and caps length", () => { assert.equal(sanitizeUrl("javascript:alert(1)"), undefined); assert.equal(sanitizeUrl("vbscript:msgbox(1)"), undefined); assert.equal(sanitizeUrl("data:text/html,"), undefined); assert.equal(sanitizeUrl(" https://example.com/a.jpg "), "https://example.com/a.jpg"); assert.equal(sanitizeUrl("x".repeat(5000)).length, SANITIZE_LIMITS.previewUrl); }); test("sanitizeCurrency normalizes and falls back", () => { assert.equal(sanitizeCurrency(" chf "), "CHF"); assert.equal(sanitizeCurrency("EUR"), "EUR"); assert.equal(sanitizeCurrency(""), "EUR"); assert.equal(sanitizeCurrency(undefined), "EUR"); }); // --------------------------------------------------------------------------- // 2. Formula-injection neutralizer (CSV export boundary) // --------------------------------------------------------------------------- console.log("\n[2] CSV formula-injection neutralizer"); test("prefixes = + - @ tab CR payloads with a single quote", () => { assert.equal(neutralizeFormulaPrefix('=HYPERLINK("http://evil","x")'), "'=HYPERLINK(\"http://evil\",\"x\")"); assert.equal(neutralizeFormulaPrefix("+cmd|' /C calc'!A0"), "'+cmd|' /C calc'!A0"); assert.equal(neutralizeFormulaPrefix("@SUM(1+1)"), "'@SUM(1+1)"); assert.equal(neutralizeFormulaPrefix("-cmd|'/C calc'!A0"), "'-cmd|'/C calc'!A0"); assert.equal(neutralizeFormulaPrefix("\t=1+1"), "'\t=1+1"); assert.equal(neutralizeFormulaPrefix("\r=1+1"), "'\r=1+1"); }); test("leaves plain numbers (incl. negative credit notes) untouched", () => { assert.equal(neutralizeFormulaPrefix("-5,00"), "-5,00"); assert.equal(neutralizeFormulaPrefix("+1,25"), "+1,25"); assert.equal(neutralizeFormulaPrefix("123"), "123"); assert.equal(neutralizeFormulaPrefix(""), ""); assert.equal(neutralizeFormulaPrefix("'=already-safe"), "'=already-safe"); }); // --------------------------------------------------------------------------- // 3. Stored-receipt schema: malicious input → sanitized safe output // --------------------------------------------------------------------------- console.log("\n[3] Stored-receipt schema (sanitize before store)"); test("malicious receipt is accepted and fully sanitized (no active markup)", () => { const result = sanitizeReceiptBatch({ receipts: [maliciousReceipt()] }); assert.ok(result.ok, `expected ok, got: ${result.error}`); const r = result.receipts[0]; assert.equal(r.merchant.name, "alert(1)"); assert.equal(r.merchant.address, null); // whole tag removed → empty → null assert.equal(r.receiptNumber, '=HYPERLINK("http://evil.example","x")'); // inert text at rest assert.equal(r.suggestedCategory, "@SUM(1+1)"); // inert text at rest assert.equal(r.rawText, "Zeile 2\nZeile 3"); assert.equal(r.lineItems[0].description, "+cmd|' /C calc'!A0"); assert.equal(r.lineItems[1].description, ""); // tag removed entirely assert.equal(r.validation.reviewReason, "alert(6)"); assert.equal(r.validation.issues[0].message, ""); // tag removed entirely assert.equal(r.validation.issues[0].severity, "warning"); const serialized = JSON.stringify(r); assert.ok(!serialized.includes("<"), "no angle bracket may survive in stored data"); assert.ok(!serialized.includes("\u0000"), "no NUL may survive in stored data"); }); test("markup inside date / receiptNumber is stripped, not rejected", () => { const result = sanitizeReceiptBatch( validReceipt({ date: { isoDate: "2026-08-15", time: null, confidence: 0.9 } }) ); assert.ok(result.ok); assert.equal(result.receipts[0].date.isoDate, "2026-08-15"); }); test("legacy German date format is preserved (sanitized), not rejected", () => { const result = sanitizeReceiptBatch( validReceipt({ date: { isoDate: "15.08.2026", time: null, confidence: 0.9 } }) ); assert.ok(result.ok); assert.equal(result.receipts[0].date.isoDate, "15.08.2026"); }); test("oversized strings are truncated per policy", () => { const result = sanitizeReceiptBatch( validReceipt({ merchant: { name: "X".repeat(5000), address: null, taxId: null, confidence: 1 } }) ); assert.ok(result.ok); assert.equal(result.receipts[0].merchant.name.length, SANITIZE_LIMITS.merchantName); }); // --------------------------------------------------------------------------- // 4. Numbers // --------------------------------------------------------------------------- console.log("\n[4] Numbers"); test("negative amounts are kept (credit notes are a domain case)", () => { const result = sanitizeReceiptBatch( validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40 }) ); assert.ok(result.ok); assert.equal(result.receipts[0].totalAmount.value, -50); assert.equal(result.receipts[0].netAmount, -40); }); test("huge amounts are clamped to ±1e10 (fits numeric(12,2))", () => { const result = sanitizeReceiptBatch( validReceipt({ totalAmount: { value: 1e12, confidence: 1 } }) ); assert.ok(result.ok); assert.equal(result.receipts[0].totalAmount.value, SANITIZE_LIMITS.maxAmountAbs); }); test("tax rate percent is clamped to 0..100", () => { const result = sanitizeReceiptBatch( validReceipt({ taxBreakdown: [{ ratePercent: 150, taxAmount: 10, netAmount: 100 }], }) ); assert.ok(result.ok); assert.equal(result.receipts[0].taxBreakdown[0].ratePercent, 100); }); test("NaN / Infinity amounts are REJECTED (structurally invalid)", () => { const nan = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: NaN, confidence: 1 } })); assert.ok(!nan.ok); assert.ok(nan.error.includes("totalAmount.value"), nan.error); const inf = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: Infinity, confidence: 1 } })); assert.ok(!inf.ok); }); test("non-number in a number field is rejected", () => { const result = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: "12.5", confidence: 1 } })); assert.ok(!result.ok); }); // --------------------------------------------------------------------------- // 5. Currency // --------------------------------------------------------------------------- console.log("\n[5] Currency"); test("3-letter ISO currency passes, lowercase is normalized", () => { const ok = sanitizeReceiptBatch(validReceipt({ currency: "chf" })); assert.ok(ok.ok); assert.equal(ok.receipts[0].currency, "CHF"); }); test("missing currency defaults to EUR", () => { const r = validReceipt(); delete r.currency; const ok = sanitizeReceiptBatch(r); assert.ok(ok.ok); assert.equal(ok.receipts[0].currency, "EUR"); }); test("bad currency (symbol / 2-letter / number) is rejected with 400-style error", () => { for (const bad of ["€", "US", "euro", 123]) { const result = sanitizeReceiptBatch(validReceipt({ currency: bad })); assert.ok(!result.ok, `currency ${JSON.stringify(bad)} should be rejected`); assert.ok(result.error.includes("currency"), result.error); } }); // --------------------------------------------------------------------------- // 6. Batch shape handling (mirrors POST /api/receipts) // --------------------------------------------------------------------------- console.log("\n[6] Batch shapes & limits"); test("accepts array, {receipts}, {receipt} and single-receipt bodies", () => { const base = validReceipt(); assert.ok(sanitizeReceiptBatch([base]).ok); assert.ok(sanitizeReceiptBatch({ receipts: [base] }).ok); assert.ok(sanitizeReceiptBatch({ receipt: base }).ok); assert.ok(sanitizeReceiptBatch(base).ok); }); test("rejects empty payloads and >500 receipts with the historic 400 messages", () => { assert.equal(sanitizeReceiptBatch({}).error, "No receipts provided in payload."); assert.equal(sanitizeReceiptBatch([]).error, "No receipts provided in payload."); assert.equal(sanitizeReceiptBatch(Array(501).fill(validReceipt())).error, "Too many receipts in payload."); }); test("rejects structurally invalid receipts with a clear indexed error", () => { const broken = validReceipt({ merchant: undefined }); const result = sanitizeReceiptBatch({ receipts: [validReceipt(), broken] }); assert.ok(!result.ok); assert.ok(result.error.includes("index 1"), result.error); assert.ok(result.error.includes("merchant"), result.error); }); test("rejects missing / oversized receipt ids", () => { assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "" })).ok); assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "x".repeat(100) })).ok); }); test("sanitizeReceipt single variant returns null on failure, sanitized data on success", () => { assert.equal(sanitizeReceipt(validReceipt({ currency: "€" })), null); const r = sanitizeReceipt(validReceipt({ merchant: { name: "REWE", address: null, taxId: null, confidence: 1 } })); assert.ok(r !== null); assert.equal(r.merchant.name, "REWE"); }); test("StoredReceiptSchema keeps passthrough fields (boundingBoxes etc.)", () => { const r = validReceipt({ boundingBoxes: { merchant: { x: 1, y: 2, width: 3, height: 4 } } }); const parsed = StoredReceiptSchema.safeParse(r); assert.ok(parsed.success); assert.deepEqual(parsed.data.boundingBoxes, r.boundingBoxes); }); // --------------------------------------------------------------------------- // 7. CSV generator: no cell may start with a formula character // --------------------------------------------------------------------------- console.log("\n[7] CSV generator output"); function evilCsvReceipt() { return validReceipt({ merchant: { name: '=HYPERLINK("http://evil.example","x")', address: null, taxId: null, confidence: 1 }, receiptNumber: "+cmd|' /C calc'!A0", suggestedCategory: "@SUM(1+1)", rawText: "\nbold", taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }], }); } test("malicious merchant/receiptNumber/category cells are neutralized in CSV", () => { const csv = generateAccountingCsv([evilCsvReceipt()], { locale: "de" }); assert.ok(!csv.includes('"=HYPERLINK'), "raw formula must not appear quoted"); assert.ok(csv.includes("'=HYPERLINK"), "neutralized cell must be prefixed with a quote"); assert.ok(csv.includes("'+cmd|"), "DDE payload must be neutralized"); const lines = csv.replace(/^\uFEFF/, "").split("\r\n").filter((l) => l.length > 0); assert.ok(lines.length >= 2, "header + at least one data row"); for (let i = 1; i < lines.length; i++) { for (const cell of splitCsvLine(lines[i])) { assert.ok( !/^[=+\-@\t\r]/.test(cell), `CSV cell on line ${i + 1} starts with a formula char: ${JSON.stringify(cell)}` ); } } }); test("negative credit-note amounts still export as plain numbers", () => { const csv = generateAccountingCsv( [validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40, currency: "EUR" })], { locale: "de" } ); assert.ok(csv.includes('"-50,00"'), "negative amount stays a number cell"); }); // --------------------------------------------------------------------------- // 8. Excel generator: user values are written as plain strings, never formulas // --------------------------------------------------------------------------- console.log("\n[8] Excel generator round-trip"); test("merchant '=HYPERLINK(...)' is written as a plain STRING cell, not a formula", async () => { const buffer = await generateDualSheetExcel([evilCsvReceipt()], { locale: "de" }); const wb = new ExcelJS.Workbook(); await wb.xlsx.load(buffer); const sheet = wb.getWorksheet("Belegübersicht"); assert.ok(sheet, "overview sheet exists"); const merchantCell = sheet.getCell(2, 3); // col C = merchant assert.equal(typeof merchantCell.value, "string"); assert.equal(merchantCell.value, '=HYPERLINK("http://evil.example","x")'); assert.equal(merchantCell.formula, undefined, "cell must not carry a formula"); const itemsSheet = wb.getWorksheet("Einzelpositionen Detail"); assert.ok(itemsSheet, "line-items sheet exists"); const descCell = itemsSheet.getCell(2, 4); // col D = description assert.equal(typeof descCell.value, "string"); assert.equal(descCell.formula, undefined, "description cell must not carry a formula"); }); // --------------------------------------------------------------------------- // 9. PDF generator: renders literal text, no formula/HTML interpretation // --------------------------------------------------------------------------- console.log("\n[9] PDF generator"); test("PDF with malicious strings still generates a valid PDF document", async () => { const pdf = await generateReceiptPdf([evilCsvReceipt()], { locale: "de" }); assert.ok(pdf instanceof Uint8Array && pdf.length > 100); const header = Buffer.from(pdf.slice(0, 5)).toString("latin1"); assert.equal(header, "%PDF-"); }); // --------------------------------------------------------------------------- // Summary // --------------------------------------------------------------------------- console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) { console.error("\nFailed tests:"); for (const f of failures) console.error(` - ${f.name}: ${f.err.message}`); process.exit(1); } process.exit(0);