1// CSV-cell escaping for the in-browser data export (#289). 2// 3// exportAsCSV builds a downloadable CSV from archive fields (title, summary, 4// url, publication, author) that originate in scraped page content and curator 5// submissions -- both untrusted. A cell whose first character is a spreadsheet 6// formula trigger is executed when the CSV is opened in Excel/Sheets/Numbers, 7// enabling =HYPERLINK(...) data exfiltration or =cmd|'/c ...'!A1 DDE attacks. 8// 9// The trigger set mirrors the canonical server-side neutraliser 10// (backend/src/rosen_scraper/csv_safety.py CSV_INJECTION_PREFIXES) so the 11// in-browser export and the canonical CSV neutralise the same characters: 12// the four OWASP formula prefixes plus the leading control characters 13// (tab/CR/LF) that importers strip or treat as formula starts. 14 15const CSV_FORMULA_TRIGGERS = new Set(['=', '+', '-', '@', '\t', '\r', '\n']); 16 17/** 18 * Escape one value for a CSV cell: neutralise formula injection, then apply 19 * RFC 4180 quoting. 20 * 21 * A leading formula trigger is prefixed with a single quote so spreadsheets 22 * treat the cell as text. A value already escaped on disk (e.g. "'@handle") 23 * begins with an apostrophe -- not itself a trigger -- so it is not 24 * double-escaped. The cell is then wrapped in double quotes (and any embedded 25 * quote doubled) when it contains a comma, quote, or newline. 26 * 27 * @param {*} value - raw cell value (coerced to string; null/undefined -> '') 28 * @returns {string} the escaped cell ready to join into a CSV row 29 */ 30export const escapeCsvCell = (value) => { 31 if (value === null || value === undefined) return ''; 32 let str = String(value); 33 if (str && CSV_FORMULA_TRIGGERS.has(str[0])) { 34 str = "'" + str; 35 } 36 // Quote on any character that can break CSV structure. Both \n and a bare \r 37 // are record terminators in RFC 4180, so a cell like "'\r=HYPERLINK(...)" 38 // must be wrapped â otherwise the \r starts a new row and the neutralised 39 // formula text leads the next line, defeating the leading-apostrophe escape. 40 if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { 41 return `"${str.replace(/"/g, '""')}"`; 42 } 43 return str; 44};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.