Spreadsheets run a cell that starts with =, +, -, @, tab or carriage return. Before writing a user value, prefix such a cell with a single quote so it stays text, then quote with a CSV library for RFC 4180. Commons CSV (like other writers) quotes but does not neutralize, so quoting alone does not stop formula execution.
- Find every export that writes user-entered data to CSV or anything a spreadsheet opens.
- Neutralize each value: if its first non-space character is =, +, -, @, a tab or a carriage return, prefix it with a single quote ('). Do this before serializing.
- Serialize with a CSV library (Apache Commons CSV here, CSVFormat.RFC4180), which quotes a field containing a comma, a quote or a newline. This keeps the file well formed; it does not stop formulas.
- Strip leading spaces before the first-character check, or neutralize on a leading space too, so " =1+1" does not slip through.
- Serve as text/csv with Content-Disposition: attachment.
- Add regression tests that parse the generated CSV and assert no cell begins with a formula trigger, and that commas, quotes and newlines 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
@Override
public String toCsv(List<Contact> contacts) {
StringBuilder csv = new StringBuilder("name,note\r\n");
for (Contact contact : contacts) {
csv.append(contact.name()).append(',').append(contact.note()).append("\r\n");
}
return csv.toString();
}With IMPL=vulnerable, 6 of 6 tests fail (2 exploit tests).
The fix
// The leading characters a spreadsheet treats as the start of a formula.
private static final Pattern FORMULA_START = Pattern.compile("^[=+\\-@\t\r]");
private static String neutralize(String value) {
// Look past leading spaces, as a spreadsheet does, before deciding.
return FORMULA_START.matcher(value.replaceFirst("^ +", "")).find() ? "'" + value : value;
}
@Override
public String toCsv(List<Contact> contacts) {
StringBuilder out = new StringBuilder();
CSVFormat format = CSVFormat.RFC4180.builder().setHeader("name", "note").get();
try (CSVPrinter printer = new CSVPrinter(out, format)) {
for (Contact contact : contacts) {
printer.printRecord(neutralize(contact.name()), neutralize(contact.note()));
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return out.toString();
}With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
@Override
public String toCsv(List<Contact> contacts) {
StringBuilder out = new StringBuilder();
CSVFormat format = CSVFormat.RFC4180.builder().setHeader("name", "note").get();
try (CSVPrinter printer = new CSVPrinter(out, format)) {
for (Contact contact : contacts) {
printer.printRecord(contact.name(), contact.note());
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return out.toString();
}With IMPL=wrong, 2 of 6 tests fail (2 exploit tests).
Route: a download a spreadsheet will open
// The contacts were typed by the public. The export is downloaded, but a spreadsheet opens
// it, so a cell must never be read as a formula.
@GetMapping("/export")
ResponseEntity<String> export() {
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"contacts.csv\"")
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
.body(exporter.toCsv(contacts));
}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.
@Test
void exploitNoExportedCellIsReadAsAFormulaByASpreadsheet() throws Exception {
List<List<String>> rows = export();
List<String> offenders = new ArrayList<>();
for (List<String> row : rows.subList(1, rows.size())) {
for (String cell : row) {
if (FORMULA_START.matcher(cell).find()) {
offenders.add(cell);
}
}
}
assertThat(offenders).as("cells read as a formula").isEmpty();
}
@Test
void exploitTheHyperlinkPayloadIsNeutralizedNotJustQuoted() throws Exception {
String cell = export().stream()
.flatMap(List::stream)
.filter(value -> value.contains("HYPERLINK"))
.findFirst()
.orElseThrow(() -> new IOException("the payload is not in the export"));
assertThat(cell).doesNotStartWith("=");
}- Assuming a CSV library makes the file safe. Commons CSV's RFC 4180 quoting fixes structure, not formulas: "=HYPERLINK(...)" in a quoted field is still a live formula once the spreadsheet strips the quotes. That is this card's wrong implementation.
- Missing the less obvious triggers +, -, @, and a leading tab or carriage return; = alone is not the whole list.
- A leading space hides a trigger from a naive check but not from the spreadsheet; strip leading spaces before deciding.
- Prefixing every -5 or +44 with a quote turns legitimate numbers into text. If round-tripping matters, neutralize only non-numeric values and document it.
- The same payloads apply to TSV and clipboard data; neutralize the value whatever writes the file.
- 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/java-spring does this for you).
- Export cells beginning with =, +, -, @, tab and carriage return, parse the CSV back, and assert no cell's value begins with one of those; confirm commas, quotes and newlines survive.
- Opening the vulnerable and wrong exports in a spreadsheet runs the =HYPERLINK and =1+1 cells; the fixed export shows them as text. Do not rely on a scanner: no Semgrep or CodeQL rule covers CWE-1236, so the parsing tests are the control.
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/java | 0 findings. No registry rule models CSV formula injection or Commons CSV, so the vulnerable, fixed and wrong exporters look the same.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (src/main/java) |
No Semgrep or CodeQL query covers CWE-1236 for Java; the tool cannot know which strings a spreadsheet will open. The regression tests, which parse the CSV and check each cell, are the control.
| Component | Version |
|---|---|
| Language | Java |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (spring-boot-starter-webmvc) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.apache.commons:commons-csv | 1.14.1 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csv-formula-injection/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs csv-formula-injection/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- java OpenJDK 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- commonsCsv 1.14.1
- junitJupiter 6.0.3
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 6 of 6 tests fail (2 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 6/6 pass; IMPL=vulnerable 6/6 fail (both exploit tests, plus the functional tests, because unquoted values with commas and quotes break the structure); IMPL=wrong 2/6 fail (both exploit tests: Commons CSV quotes correctly for RFC 4180 but never neutralizes a leading =, +, -, @, tab or carriage return).
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.