diff --git a/apps/api/internal/service/export.go b/apps/api/internal/service/export.go index d73af7b..98be6f7 100644 --- a/apps/api/internal/service/export.go +++ b/apps/api/internal/service/export.go @@ -106,6 +106,23 @@ func (s *ExportService) ListHistory(ctx context.Context, slug string, userID uui const exportPageSize = 500 +// neutralizeFormula guards a spreadsheet cell against formula injection. Excel, +// LibreOffice and Sheets execute a cell whose text begins with =, +, -, @, tab +// or carriage return as a formula, so user-controlled text (issue/project/state +// names) that starts with one of those is prefixed with a single quote, which +// forces it to be treated as literal text. Non-string values pass through. +func neutralizeFormula(v interface{}) interface{} { + s, ok := v.(string) + if !ok || s == "" { + return v + } + switch s[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + s + } + return v +} + func (s *ExportService) buildWorkbook(ctx context.Context, projectIDs []uuid.UUID) ([]byte, error) { f := excelize.NewFile() defer func() { _ = f.Close() }() @@ -158,7 +175,7 @@ func (s *ExportService) buildWorkbook(ctx context.Context, projectIDs []uuid.UUI } for c, v := range values { cell, _ := excelize.CoordinatesToCellName(c+1, row) - _ = f.SetCellValue(sheet, cell, v) + _ = f.SetCellValue(sheet, cell, neutralizeFormula(v)) } row++ } diff --git a/apps/api/internal/service/export_formula_test.go b/apps/api/internal/service/export_formula_test.go new file mode 100644 index 0000000..bcc886f --- /dev/null +++ b/apps/api/internal/service/export_formula_test.go @@ -0,0 +1,25 @@ +package service + +import "testing" + +func TestNeutralizeFormula(t *testing.T) { + cases := []struct { + in interface{} + want interface{} + }{ + {"=HYPERLINK(\"http://evil\")", "'=HYPERLINK(\"http://evil\")"}, + {"+1+2", "'+1+2"}, + {"-2+3", "'-2+3"}, + {"@SUM(A1)", "'@SUM(A1)"}, + {"\tstartsWithTab", "'\tstartsWithTab"}, + {"\rstartsWithCR", "'\rstartsWithCR"}, + {"Normal title", "Normal title"}, + {"", ""}, + {42, 42}, // non-string values pass through untouched + } + for _, tc := range cases { + if got := neutralizeFormula(tc.in); got != tc.want { + t.Errorf("neutralizeFormula(%q) = %q; want %q", tc.in, got, tc.want) + } + } +}