From 9793256b5d43d7d94076e26201a8340b278c03cf Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Fri, 17 Jul 2026 00:11:05 +0400 Subject: [PATCH] fix(api): neutralize formula injection in the issue export Issue, project and state names were written straight into spreadsheet cells, so a title like =HYPERLINK(...), +cmd, -2+3 or @SUM(...) became a live formula when the exported .xlsx was opened. Titles are user-controlled, so this was a stored-then-exported injection. Prefix any cell text that starts with =, +, -, @, tab or CR with a single quote so it's treated as literal text. Closes #331 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/service/export.go | 19 +++++++++++++- .../internal/service/export_formula_test.go | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 apps/api/internal/service/export_formula_test.go 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) + } + } +}