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.
- 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.
- 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.
- Keep using the CSV library for field quoting (commas, quotes, newlines); the formula-trigger prefix is a separate, earlier step on the field value.
- 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.
- 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.
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
def self.format_cell(value)
value.to_s
endWith IMPL=vulnerable, 6 of 10 tests fail (6 exploit tests).
The fix
def self.format_cell(value)
text = value.to_s
text.start_with?(*TRIGGERS) ? "'#{text}" : text
endWith IMPL=fixed, 10 of 10 tests pass.
A fix that looks safe but is not
def self.format_cell(value)
text = value.to_s
text.start_with?("=") ? "'#{text}" : text
endWith IMPL=wrong, 4 of 10 tests fail (4 exploit tests).
Building the CSV
# 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"
endThe 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.
# 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- 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.
- 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).
- 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.
- Scanners do not help here: Semgrep's packs, Brakeman and CodeQL report nothing about CSV formula injection on any implementation.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/ruby, p/owasp-top-ten, p/security-audit | No 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 |
| CodeQL | rb/csv-injection | There 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 |
| Brakeman | general check setBrakeman 8.1.0 | No 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.
| Component | Version |
|---|---|
| Language | Ruby |
| Runtime | Ruby 4.0.7 |
| Framework | Ruby on Rails 8.1.4 (API controller, CSV.generate, send_data) |
| rails (railties, actionpack, activerecord) | 8.1.4 |
| csv | 3.3.6 |
| sqlite3 | 2.9.6 |
| minitest | 6.0.6 |
| minitest-reporters (JUnitReporter) | 1.8.0 |
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.
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install# 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.rbWithout 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.
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'# 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.
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.