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.
This commit is contained in:
Doxa Maps 2026-09-30 21:09:29 +00:00
parent 6e1a335343
commit b138c876d5
2 changed files with 20 additions and 4 deletions

View file

@ -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.')

View file

@ -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
}