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:
parent
6e1a335343
commit
b138c876d5
2 changed files with 20 additions and 4 deletions
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue