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.

← All articles