ruby / csv
Spreadsheets treat any cell starting with =, +, -, @,
tab, or carriage return as a formula. A user-controlled cell
that begins with one of those characters can execute when the
file is opened in Excel, Numbers, or LibreOffice. Some payloads
exfiltrate data via HYPERLINK or WEBSERVICE.
Neutralize on export
Prefix risky cells with a leading single quote. Spreadsheets treat the quote as a literal-text marker and strip it from display:
require "csv"
DANGEROUS = /\A[=+\-@\t\r]/
def safe_cell(value)
s = value.to_s
s.match?(DANGEROUS) ? "'#{s}" : s
end
Ruby's CSV library handles RFC 4180 quoting and escaping.
Formula injection is a separate problem at the cell-content
layer.
At the boundary
Apply the helper to every cell that came from user input. The safest approach is one wrapper at the export boundary that covers every value, no exceptions:
def safe_row(values)
values.map { |v| safe_cell(v) }
end
CSV.generate do |csv|
csv << ["Name", "Email", "Notes"]
rows.each do |row|
csv << safe_row([row["name"], row["email"], row["notes"]])
end
end
Numbers and booleans pass through unchanged because their
to_s does not match the dangerous prefix set.
Tests
def test_formula_prefix_is_neutralized
ok { safe_cell("=1+1") == "'=1+1" }
ok { safe_cell("+cmd|'/c'") == "'+cmd|'/c'" }
ok { safe_cell("-2+3") == "'-2+3" }
ok { safe_cell("@SUM(A1:A9)") == "'@SUM(A1:A9)" }
ok { safe_cell("ok") == "ok" }
ok { safe_cell(42) == "42" }
end
A Fmt::Csv.row helper that wraps every cell makes the rule
mechanical. See ruby / escaping for the same
pattern applied to HTML output.