From b138c876d5291864c0eb05fe5cecc6f75a1a7a35 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 21:09:29 +0000 Subject: [PATCH] CSV exports: a value starting with TAB or CR is neutralised too, and the gate asserts it esc() prefixed a single quote to a value starting with = + - @, but OWASP's formula triggers also include a leading TAB and CR, which a spreadsheet can strip before reading the = behind them. Every map's CSV export shares this escaper. The shape gate carried a formula probe but never checked it came back neutralised; it now does, for =, TAB and CR, by slug. --- internal/check-csv-export-shape.mjs | 18 ++++++++++++++++-- library/composables/useCsvExport.js | 6 ++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/check-csv-export-shape.mjs b/internal/check-csv-export-shape.mjs index ac2115214..abfa61ee8 100644 --- a/internal/check-csv-export-shape.mjs +++ b/internal/check-csv-export-shape.mjs @@ -17,7 +17,7 @@ * were a terminal's rendering, not the bytes. * - "quoting is broken" — FALSE. esc() is genuinely RFC-4180: it quotes when , " CR or LF appear and * doubles internal quotes. Embedded commas, quotes AND newlines survive a strict parser round trip. - * - "CSV injection" — already handled; a leading = + - @ is prefixed with a single quote. + * - "CSV injection" — handled; a leading = + - @ TAB or CR is prefixed with a single quote (asserted in 5). * Only the POSITION was wrong. Chasing the other three would have produced churn and no fix. * * ★ WHY NO EXISTING GATE SAW IT. 46 gate-ish scripts exist and not one parses the export. The bug is @@ -89,8 +89,10 @@ const rows = [ { slug: 'kluet', name: 'Kluet', religion: 'Islam - Sunni', imb_people_description: 'Line one\nLine two, with a comma' }, { slug: 'inj', name: '=SUM(A1)', religion: 'Islam - Sunni', imb_people_description: 'injection probe' }, + { slug: 'inj-tab', name: '\t=cmd', religion: 'Islam - Sunni', imb_people_description: 'injection probe, a leading TAB' }, + { slug: 'inj-cr', name: '\r=cmd', religion: 'Islam - Sunni', imb_people_description: 'injection probe, a leading CR' }, ] -const provenance = '# Research Dashboard export — scope: filter result; filters: religion=Islam - Sunni; rows: 3' +const provenance = '# Research Dashboard export — scope: filter result; filters: religion=Islam - Sunni; rows: 5' const body = [cols.map(esc).join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))] const csv = (provenanceFirst ? [provenance, ...body] : [...body, provenance]).join('\r\n') @@ -164,6 +166,18 @@ if (!(last.length === 1 && String(last[0]).startsWith('#'))) { ' between a dataset and an anecdote. Keep it — just keep it last.') } +// 5 — every formula trigger comes back NEUTRALISED (a leading single quote), so a spreadsheet shows it as text. +// The probes cover = and OWASP's other two leading triggers, TAB and CR, which a spreadsheet can strip before +// reading the = behind them. Checked by SLUG, so the rows above may be reordered. +const bySlug = new Map(dataRows.map((r) => [r[0], r])) +for (const [slug, raw] of [['inj', '=SUM(A1)'], ['inj-tab', '\t=cmd'], ['inj-cr', '\r=cmd']]) { + const got = bySlug.get(slug) && bySlug.get(slug)[1] + if (got !== "'" + raw) { + fail(`the formula probe ${JSON.stringify(raw)} came back as ${JSON.stringify(got)}, not neutralised with a leading quote.`, + ' esc() must prefix a single quote to a value starting with = + - @ TAB or CR (CSV injection).') + } +} + // Reaching here under --self-test means the gate WAVED THROUGH a known-broken export. if (SELF_TEST) { console.error('CSV-SHAPE SELF-TEST: FAIL — the gate PASSED a deliberately broken export.') diff --git a/library/composables/useCsvExport.js b/library/composables/useCsvExport.js index ae8f20017..ef212ed35 100644 --- a/library/composables/useCsvExport.js +++ b/library/composables/useCsvExport.js @@ -199,10 +199,12 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC for (const k of Object.keys(r)) o[k] = cell(r[k]) return o } - // Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection). + // Quote if the field has , " CR LF; double internal quotes; neutralize a leading = + - @ TAB or CR (CSV + // injection: OWASP's full list of formula triggers, since a spreadsheet can strip a leading TAB/CR and then + // read the = that follows as a formula). function esc(v) { let s = v === null || v === undefined ? '' : String(v) - if (/^[=+\-@]/.test(s)) s = "'" + s + if (/^[=+\-@\t\r]/.test(s)) s = "'" + s if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"' return s }