Files
scan-receipts/scripts/verify_sanitize.mjs
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

511 lines
19 KiB
JavaScript

#!/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: "<script>alert(1)</script>",
address: "<img src=x onerror=alert(2)>",
taxId: "javascript:alert(3)",
confidence: 1,
},
receiptNumber: '=HYPERLINK("http://evil.example","x")',
suggestedCategory: "@SUM(1+1)",
rawText: '<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007',
lineItems: [
{ description: "+cmd|' /C calc'!A0", quantity: 1, price: 12.34, unitPrice: 12.34, taxRate: 19 },
{ description: "<svg onload=alert(5)>", quantity: 2, price: 0.5, taxRate: null },
],
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: true,
reviewField: "merchant",
reviewReason: "<script>alert(6)</script>",
issues: [{ field: "merchant", severity: "warning", message: "<img onerror=alert(7)>" }],
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("<script>alert(1)</script>", 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('<img src=x onerror=alert(1)>', 200), "");
assert.equal(sanitizeText("<svg onload=alert(5)>", 200), "");
});
test("strips comments / doctype / CDATA / processing instructions", () => {
assert.equal(sanitizeText("a<!-- x -->b", 200), "ab");
assert.equal(sanitizeText("a<!DOCTYPE html>b", 200), "ab");
assert.equal(sanitizeText("a<![CDATA[ x ]]>b", 200), "ab");
assert.equal(sanitizeText("a<?php echo 1 ?>b", 200), "ab");
});
test("malformed double-angle markup cannot survive", () => {
assert.equal(sanitizeText("<<script>alert(1)", 200), "alert(1)");
});
test("strips control chars (NUL, BEL, ESC …) and collapses whitespace", () => {
assert.equal(sanitizeText(" Hello\u0000World\u0007!\t\t", 200), "HelloWorld!");
assert.equal(sanitizeText("a\u0000\nb", 200), "a b");
});
test("trims and caps length", () => {
assert.equal(sanitizeText(" x ", 200), "x");
assert.equal(sanitizeText("x".repeat(5000), 200).length, 200);
assert.equal(sanitizeText(12345, 200), ""); // non-string → ""
});
test("multiline OCR sanitizer keeps newlines, strips markup and NUL", () => {
const out = sanitizeMultilineText("<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\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,<script>alert(1)</script>"), 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 <img …> 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, ""); // <svg …> tag removed entirely
assert.equal(r.validation.reviewReason, "alert(6)");
assert.equal(r.validation.issues[0].message, ""); // <img …> 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-<b>08</b>-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: "<b>REWE</b>", 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: "<img src=x onerror=alert(1)>\n<b>bold</b>",
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);