Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix CSV formula injection in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Before writing a user-controlled value to a cell, prefix it with a single quote when it begins with any formula trigger: =, +, -, @, tab (0x09) or carriage return (0x0D). CSV quoting (CSV.generate) still handles commas, quotes and newlines; it does not stop a spreadsheet from running a cell that starts with one of those characters.

Stack
  • Language Ruby
  • Runtime Ruby 4.0.7
  • Framework Ruby on Rails 8.1.4 (API controller, CSV.generate, send_data)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 23 KB
On this page

Steps

  1. Find every place a user-controlled value reaches a CSV, spreadsheet or TSV export: CSV.generate, CSV.open, send_data of a built string, and any third-party exporter.
  2. Write one formatter that prefixes a value with a single quote when it begins with =, +, -, @, a tab or a carriage return, and run every user-controlled cell through it. Static header labels do not need it.
  3. Keep using the CSV library for field quoting (commas, quotes, newlines); the formula-trigger prefix is a separate, earlier step on the field value.
  4. Decide whether to neutralise or reject: prefixing turns a value into text in the spreadsheet, which changes what the recipient sees; some teams reject values that begin with a trigger instead.
  5. Add regression tests that export each trigger character (=, +, -, @, tab) and a DDE payload, and assert the exported cell does not begin with a trigger, plus functional tests that ordinary names round-trip.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/csv_export/csv_export/vulnerable.rbRuby
def self.format_cell(value)
  value.to_s
end

With IMPL=vulnerable, 6 of 10 tests fail (6 exploit tests).

The fix

Fixedapp/csv_export/csv_export/fixed.rbRuby
def self.format_cell(value)
  text = value.to_s
  text.start_with?(*TRIGGERS) ? "'#{text}" : text
end

With IMPL=fixed, 10 of 10 tests pass.

A fix that looks safe but is not

Wrong fixapp/csv_export/csv_export/wrong.rbRuby
def self.format_cell(value)
  text = value.to_s
  text.start_with?("=") ? "'#{text}" : text
end

With IMPL=wrong, 4 of 10 tests fail (4 exploit tests).

Building the CSV

app/controllers/contacts_controller.rbRuby
# Build the CSV, running every user-controlled cell through the IMPL's formatter. The header row
# is static, so it is written as-is; every data cell is formatted.
def index
  formatter = CsvExport.current
  csv = CSV.generate do |out|
    out << Contact::COLUMNS
    Contact.order(:id).each do |contact|
      out << Contact::COLUMNS.map { |column| formatter.format_cell(contact.public_send(column)) }
    end
  end
  send_data csv, type: "text/csv; charset=utf-8", disposition: "attachment", filename: "contacts.csv"
end

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/contacts_test.rbRuby
# Exploit: each display name is a formula. The secure outcome is a cell that no spreadsheet reads
# as a formula (it must not begin with a trigger character).

test "exploit: a cell beginning with = is neutralised" do
  assert_neutralised("=1+1")
end

test "exploit: a cell beginning with + is neutralised" do
  assert_neutralised("+1+1")
end

test "exploit: a cell beginning with - is neutralised" do
  assert_neutralised("-1+1")
end

test "exploit: a cell beginning with @ is neutralised" do
  assert_neutralised("@SUM(A1)")
end

test "exploit: a DDE command formula is neutralised" do
  assert_neutralised("=cmd|'/c calc'!A1")
end

test "exploit: a tab-led formula is neutralised" do
  assert_neutralised("\t=1+1")
end

Pitfalls

  • CSV quoting is not the fix. CSV.generate correctly quotes a field with a comma or a quote, but a spreadsheet still reads the quoted value =1+1 as a formula; neutralising the leading character is a separate step.
  • Only guarding = is the common wrong fix. A spreadsheet also starts a formula on +, -, @, a tab (0x09) and a carriage return (0x0D), so +2+2, -2+2, @SUM(1) and a tab-led payload all slip through.
  • Prefixing a legitimate value that begins with a trigger (a negative number, a value that starts with +) turns it into text, so the recipient sees a leading quote or the raw string. That is the accepted trade-off; document it or reject instead.
  • The danger is in how the recipient's spreadsheet parses the file, not in your app, so output encoding libraries and web escaping do not help; the check belongs at CSV generation.
  • A formula can do more than compute: =HYPERLINK and the legacy DDE syntax (=cmd|'/c ...'!A1) can exfiltrate data or, with the right prompts, run a command. Treat every trigger the same.
  • Apply the same rule to every export format a spreadsheet opens, including TSV and the clipboard, not only files named .csv.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csv-formula-injection/ruby-rails does this for you, in Docker if you do not have Ruby).
  2. Open an export built from a value such as =1+1 or +1+1 in a spreadsheet; the cell must display the text, not a computed result.
  3. Scanners do not help here: Semgrep's packs, Brakeman and CodeQL report nothing about CSV formula injection on any implementation.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix CSV formula injection in Ruby on Rails
ToolRuleWhat it found here
Semgrepp/ruby, p/owasp-top-ten, p/security-auditNo findings on any implementation. The semgrep-rules repository has no rule for CSV formula injection; CSV.generate writing a user-controlled value is not a modelled sink, so the vulnerable, wrong and fixed formatters look the same to every rule.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); every Ruby rule in a semgrep-rules checkout (develop 9bbf021) run on app/, then --config p/ruby, p/owasp-top-ten and p/security-audit
CodeQLrb/csv-injectionThere is no Ruby CodeQL query for CWE-1236 (CSV formula injection). CodeQL has no sink for a spreadsheet-interpreted cell, so it reports nothing on any implementation.Checked: github/codeql main at b5b8165; query list read, not executed
Brakemangeneral check setBrakeman 8.1.0No warnings about CSV injection on any implementation. Brakeman has no check for formula elements in a generated CSV. With -A it also reports ForceSSL because the card has no config/environments/production.rb, which the card leaves out as the other Rails cards do.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q --no-pager -f json /card

No Ruby scanner rule checked here flags a user-controlled value written to a CSV without neutralising a leading formula trigger. The regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (API controller, CSV.generate, send_data)
rails (railties, actionpack, activerecord)8.1.4
csv3.3.6
sqlite32.9.6
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csv-formula-injection/ruby-railsfolder.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/contacts_test.rb

# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/contacts_test.rb

# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/contacts_test.rb

Without Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/contacts_test.rb'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/contacts_test.rb'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/contacts_test.rb'

Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs csv-formula-injection/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 23 KB

Verified

Machine
Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
  • bundler 4.0.20
  • rails 8.1.4
  • csv 3.3.6
  • minitest 6.0.6
  • minitest-reporters 1.8.0
  • semgrep 1.180.0
  • brakeman 8.1.0
Matrix
fixed: 10 of 10 tests pass; vulnerable: 6 of 10 tests fail (6 exploit tests); wrong: 4 of 10 tests fail (4 exploit tests)
Test results
fixed 10/10 pass; vulnerable 6/10 fail (6 exploit tests: =, +, -, @, the DDE payload and the tab-led formula, all written raw); wrong 4/10 fail (4 exploit tests: +, -, @ and the tab-led formula, which the equals-only guard misses), each test run in a container with --network=none after install
Dependency freshness
Same pins as the other Rails cards: Rails 8.1.4 (2026-09-24) and csv 3.3.6, with uri held at 1.1.1 and prettyprint at 0.2.0 because 1.1.2 and 0.2.1 (both 2026-10-08) were three days old and are not security releases. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-1236: Improper Neutralization of Formula Elements in a CSV File
  2. OWASP: CSV Injection
  3. OWASP Top 10:2025 A05 Injection
  4. OWASP WSTG (latest): CSV Injection (WSTG-INJT-21)
  5. Ruby csv gem (bundled with Ruby): CSV.generate
  6. Rails API: ActionController::DataStreaming#send_data

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.