diff --git a/pkg/safecsv/cell.go b/pkg/safecsv/cell.go index 506c7dcad..78828766c 100644 --- a/pkg/safecsv/cell.go +++ b/pkg/safecsv/cell.go @@ -20,6 +20,12 @@ package safecsv +import ( + "strings" + "unicode" + "unicode/utf8" +) + // SanitizeRecord returns a copy of record with spreadsheet-safe cell values. func SanitizeRecord(record []string) []string { if len(record) == 0 { @@ -35,15 +41,31 @@ func SanitizeRecord(record []string) []string { } // SanitizeCell prefixes values that spreadsheet tools may interpret as formulas. +// Leading Unicode whitespace (including newlines) is ignored for detection only; +// the written cell keeps the original text with a leading single-quote escape. func SanitizeCell(value string) string { if value == "" { return value } - switch value[0] { - case '=', '+', '-', '@', '\t', '\r': - return "'" + value - default: + trimmed := strings.TrimLeftFunc(value, unicode.IsSpace) + if trimmed == "" { return value } + + r, _ := utf8.DecodeRuneInString(trimmed) + if formulaLeadingRune(r) { + return "'" + value + } + + return value +} + +func formulaLeadingRune(r rune) bool { + switch r { + case '=', '+', '-', '@', '\\', '|', '%': + return true + default: + return false + } } diff --git a/pkg/safecsv/cell_test.go b/pkg/safecsv/cell_test.go index a9f1d9dda..7d2a754be 100644 --- a/pkg/safecsv/cell_test.go +++ b/pkg/safecsv/cell_test.go @@ -36,6 +36,10 @@ func TestSanitizeCell(t *testing.T) { assert.Equal(t, "'+cmd", SanitizeCell("+cmd")) assert.Equal(t, "'-2", SanitizeCell("-2")) assert.Equal(t, "'@sum", SanitizeCell("@sum")) + assert.Equal(t, "' =1+1", SanitizeCell(" =1+1")) + assert.Equal(t, "'\n=1+1", SanitizeCell("\n=1+1")) + assert.Equal(t, "'\\evil", SanitizeCell("\\evil")) + assert.Equal(t, "'-5", SanitizeCell("-5")) } func TestWriterWrite(t *testing.T) { diff --git a/pkg/safecsv/doc.go b/pkg/safecsv/doc.go new file mode 100644 index 000000000..530e66082 --- /dev/null +++ b/pkg/safecsv/doc.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Package safecsv wraps encoding/csv for exports that may contain user-controlled +// text. Writer sanitizes every cell before encoding to reduce spreadsheet formula +// injection when a file is opened in Excel, LibreOffice Calc, Google Sheets, or +// similar tools. +// +// Use safecsv for all user-facing CSV downloads. Do not write export CSV with +// encoding/csv alone when record fields can contain untrusted or attacker-influenced +// content. +package safecsv