cd6d57839b
Blocker 1 — CSV/Banana formula injection. Neither csvEscape (ledgerService) nor the tax-report CSV escape nor the unquoted tab-separated Banana cell formatter prefixed risky leading chars, so an admin-/sender-controlled cell beginning with = + - @ TAB CR executes as a formula when the Treuhänder opens the export. New shared util neutralizeSpreadsheetFormula() prepends a single quote; wired into all three sinks (quoted CSV + unquoted Banana). Unit test pins one of each char. Blocker 2 — IMAP intake double-ingest race. received_emails.message_id was INDEX, not UNIQUE, and the poller ingested attachments BEFORE writing the audit row, so a second replica / rolling-deploy overlap double-ingested the same mail. Migration 128 makes message_id UNIQUE (nulls stay distinct); the intake now CLAIMS the message row (status='processing') BEFORE ingesting — a concurrent claim hits the unique constraint and skips cleanly (shared isUniqueViolation helper). Stale 'processing' rows (worker crashed mid-ingest) are reclaimed after 10 min so no attachment is orphaned. NOT done (deliberate): the suggested UNIQUE on inbound_documents.file_sha256 — that column is a SOFT dedup key by design (manual re-uploads are kept as flagged 'duplicate' rows + duplicate_of_id for the Duplikat disposition); a unique index would break that feature. The file race only yields an extra 'unsorted' row (a data-quality nit, caught by the existing manual Duplikat backstop), not a double-count. Rationale to be added to the PR reply.
38 lines
1.5 KiB
JavaScript
38 lines
1.5 KiB
JavaScript
const { neutralizeSpreadsheetFormula } = require('../../src/utils/spreadsheetSafe');
|
|
const { _internal } = require('../../src/services/ledgerService');
|
|
|
|
describe('neutralizeSpreadsheetFormula — CSV/Banana formula-injection defence (PR #622 blocker 1)', () => {
|
|
it.each([
|
|
['=', '=cmd|"/C calc"!A1'],
|
|
['+', '+1+1'],
|
|
['-', '-2+3'],
|
|
['@', '@SUM(1+1)'],
|
|
['tab', '\tSUM(A1)'],
|
|
['carriage-return', '\rSUM(A1)'],
|
|
])('prefixes a single quote when the cell starts with %s', (_label, payload) => {
|
|
const out = neutralizeSpreadsheetFormula(payload);
|
|
expect(out).toBe(`'${payload}`);
|
|
expect(out[0]).toBe("'");
|
|
});
|
|
|
|
it('leaves safe values untouched', () => {
|
|
expect(neutralizeSpreadsheetFormula('LBM-R-2026-0001')).toBe('LBM-R-2026-0001');
|
|
expect(neutralizeSpreadsheetFormula('Acme GmbH')).toBe('Acme GmbH');
|
|
expect(neutralizeSpreadsheetFormula('29.40')).toBe('29.40');
|
|
// A minus only mid-string is fine — only a LEADING risky char matters.
|
|
expect(neutralizeSpreadsheetFormula('Q-2026-0001')).toBe('Q-2026-0001');
|
|
});
|
|
|
|
it('coerces null/undefined to empty string', () => {
|
|
expect(neutralizeSpreadsheetFormula(null)).toBe('');
|
|
expect(neutralizeSpreadsheetFormula(undefined)).toBe('');
|
|
});
|
|
|
|
it('ledgerService.csvEscape applies the prefix AND the RFC-4180 quote wrap', () => {
|
|
// formula cell → prefixed then quote-wrapped
|
|
expect(_internal.csvEscape('=1+1')).toBe('"\'=1+1"');
|
|
// embedded quotes still doubled; safe value not prefixed
|
|
expect(_internal.csvEscape('a"b')).toBe('"a""b"');
|
|
});
|
|
});
|