Admin Messages CSV: defuse spreadsheet formula injection
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s

The sender name and message body are user-controlled; a leading =, +, -, @, tab or
CR in the CSV export would execute as a formula when a moderator opens it in a
spreadsheet. csvSafe() prefixes such values with a single quote. Unit-tested.
This commit is contained in:
Ilia Denisov
2026-06-08 22:09:26 +02:00
parent c96d714fec
commit 461e330bfc
2 changed files with 42 additions and 1 deletions
+25
View File
@@ -0,0 +1,25 @@
package server
import "testing"
// TestCSVSafe checks the CSV/spreadsheet formula-injection guard used by the admin Messages
// export: a leading formula trigger is quoted, everything else is left intact.
func TestCSVSafe(t *testing.T) {
tests := []struct{ in, want string }{
{"", ""},
{"hello", "hello"},
{"=1+1", "'=1+1"},
{"+cmd", "'+cmd"},
{"-2", "'-2"},
{"@SUM(A1)", "'@SUM(A1)"},
{"\tx", "'\tx"},
{"\rx", "'\rx"},
{"good luck", "good luck"},
{"a=b", "a=b"}, // a formula char that is not leading must be left untouched
}
for _, tc := range tests {
if got := csvSafe(tc.in); got != tc.want {
t.Errorf("csvSafe(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
@@ -213,13 +213,29 @@ func (s *Server) consoleMessagesCSV(c *gin.Context) {
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"})
for _, m := range items {
// The sender name and message body are user-controlled; defuse spreadsheet formula
// injection so a moderator opening the export can't trigger a formula.
_ = w.Write([]string{
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), m.SenderName, m.SenderIP, m.Body, m.GameID.String(),
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(),
})
}
w.Flush()
}
// csvSafe defuses CSV/spreadsheet formula injection: a value a spreadsheet would treat as a
// formula (a leading =, +, -, @, tab or CR) is prefixed with a single quote so it renders as
// plain text on open.
func csvSafe(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + s
}
return s
}
// consoleUserDetail renders one account with its stats, identities and games.
func (s *Server) consoleUserDetail(c *gin.Context) {
ctx := c.Request.Context()