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

Fix CSV formula injection in Java and Spring Boot

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

In short

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.

Stack
  • Language Java
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (spring-boot-starter-webmvc)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 19 KB
On this page

Steps

  1. Find every export that writes user-entered data to CSV or anything a spreadsheet opens.
  2. 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.
  3. 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.
  4. Strip leading spaces before the first-character check, or neutralize on a leading space too, so " =1+1" does not slip through.
  5. Serve as text/csv with Content-Disposition: attachment.
  6. 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.

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

Vulnerable…/contacts/impl/VulnerableContactExporter.javaJava
@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

Fixed…/contacts/impl/FixedContactExporter.javaJava
// 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

Wrong fix…/contacts/impl/WrongContactExporter.javaJava
@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

…/example/contacts/ContactController.javaJava
// 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 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.

Test…/example/contacts/ContactApiTest.javaJava
@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("=");
}

Pitfalls

  • 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.

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/java-spring does this for you).
  2. 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.
  3. 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.

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 Java and Spring Boot
ToolRuleWhat it found here
Semgrepp/java0 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (spring-boot-starter-webmvc)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.apache.commons:commons-csv1.14.1
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

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/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# 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 test

Each 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.

Download the project ZIP, 19 KB

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.

References

  1. OWASP Top 10:2025 A05 Injection
  2. CWE-1236: Improper Neutralization of Formula Elements in a CSV File
  3. OWASP CSV Injection
  4. Apache Commons CSV: user guide
  5. RFC 4180: Common Format and MIME Type for CSV Files

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.