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

Fix CSV formula injection in C# and ASP.NET Core

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. CsvHelper's InjectionOptions defaults to None, which quotes but does not neutralize, so set InjectionOptions.Escape: it prefixes such a cell with a single quote and still quotes for RFC 4180. Quoting alone does not stop formula execution.

Stack
  • Language C#
  • Runtime ASP.NET Core 10 minimal API on .NET 10.0.12
  • Framework ASP.NET Core 10 (minimal API)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. Find every export that writes user-entered data to CSV or anything a spreadsheet opens.
  2. Use CsvHelper with CsvConfiguration.InjectionOptions = InjectionOptions.Escape. It prefixes a field whose first character is an InjectionCharacter (=, @, +, -, tab, carriage return) with the InjectionEscapeCharacter (a single quote) and then quotes it as RFC 4180 requires.
  3. If you hand-roll CSV instead, neutralize the value first (prefix the single quote), then quote fields that contain a comma, a double quote or a newline and double the inner quotes. Both layers are needed.
  4. CsvHelper checks only the first character, so strip leading spaces from a value before writing if your data can contain them; otherwise " =1+1" slips through.
  5. Serve the file 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.csC#
public string ToCsv(IReadOnlyList<Contact> contacts)
{
    var csv = new StringBuilder("name,note\r\n");
    foreach (var contact in 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

Fixedsrc/Contacts/impl/FixedContactExporter.csC#
public string ToCsv(IReadOnlyList<Contact> contacts)
{
    var config = new CsvConfiguration(CultureInfo.InvariantCulture)
    {
        // Off by default. Escape prefixes formula-starting fields with ' and quotes them.
        InjectionOptions = InjectionOptions.Escape,
    };
    using var writer = new StringWriter();
    using var csv = new CsvWriter(writer, config);
    csv.WriteField("name");
    csv.WriteField("note");
    csv.NextRecord();
    foreach (var contact in contacts)
    {
        // CsvHelper checks only the first character, so trim leading spaces before writing
        // if your data has them: " =1+1" would otherwise pass through unescaped.
        csv.WriteField(contact.Name);
        csv.WriteField(contact.Note);
        csv.NextRecord();
    }
    csv.Flush();
    return writer.ToString();
}

With IMPL=fixed, 6 of 6 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Contacts/impl/WrongContactExporter.csC#
public string ToCsv(IReadOnlyList<Contact> contacts)
{
    // The default configuration: quoting on, injection protection off (InjectionOptions.None).
    var config = new CsvConfiguration(CultureInfo.InvariantCulture);
    using var writer = new StringWriter();
    using var csv = new CsvWriter(writer, config);
    csv.WriteField("name");
    csv.WriteField("note");
    csv.NextRecord();
    foreach (var contact in contacts)
    {
        csv.WriteField(contact.Name);
        csv.WriteField(contact.Note);
        csv.NextRecord();
    }
    csv.Flush();
    return writer.ToString();
}

With IMPL=wrong, 2 of 6 tests fail (2 exploit tests).

Route: a download a spreadsheet will open

src/Contacts/Program.csC#
// Every field was typed by the public. The export is downloaded, but a spreadsheet opens it,
// so no cell may be read as a formula.
app.MapGet("/export", (IReadOnlyList<Contact> rows, IContactExporter exporter, HttpResponse response) =>
{
    response.Headers.ContentDisposition = "attachment; filename=\"contacts.csv\"";
    return Results.Text(exporter.ToCsv(rows), "text/csv", Encoding.UTF8);
});

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.

Testtests/Contacts.Tests/ExploitTests.csC#
public sealed class ExploitTests(ContactsApiFactory factory) : IClassFixture<ContactsApiFactory>
{
    private readonly HttpClient _client = factory.CreateClient();
    private static readonly Regex FormulaStart = new("^[=+\\-@\t\r]", RegexOptions.Compiled);

    private async Task<List<List<string>>> ExportRows()
    {
        using var response = await _client.GetAsync("/export", TestContext.Current.CancellationToken);
        return Csv.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
    }

    [Fact]
    public async Task NoExportedCellIsReadAsAFormulaByASpreadsheet()
    {
        var rows = await ExportRows();
        var offenders = rows.Skip(1).SelectMany(r => r).Where(v => FormulaStart.IsMatch(v)).ToList();
        Assert.True(offenders.Count == 0, $"these cells start with a formula trigger: {string.Join(" | ", offenders)}");
    }

    [Fact]
    public async Task TheHyperlinkPayloadIsNeutralizedNotJustQuoted()
    {
        var rows = await ExportRows();
        var cell = rows.SelectMany(r => r).FirstOrDefault(v => v.Contains("HYPERLINK"));
        Assert.NotNull(cell);
        Assert.False(cell!.StartsWith('='), $"the payload is still a live formula: {cell}");
    }
}

Pitfalls

  • Leaving InjectionOptions at its default (None) and assuming CsvHelper's quoting is enough. "=HYPERLINK(...)" is valid RFC 4180 and still a live formula: the spreadsheet strips the CSV quotes and reads the leading =. That is this card's wrong implementation.
  • Forgetting the less obvious triggers +, -, @ and a leading tab or carriage return; CsvHelper's InjectionCharacters include them, a hand-rolled check often does not.
  • A leading space hides a trigger from CsvHelper's first-character check but not from the spreadsheet; trim leading spaces, or neutralize on a leading space too.
  • 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, and to any other writer whose formula protection is off by default.

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/csharp-aspnetcore does this for you).
  2. Export data with 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 the round trip.
  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 C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepp/csharp0 findings. No registry rule models CSV formula injection or CsvHelper's InjectionOptions, so the vulnerable, fixed and wrong exporters look the same.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (src only; scanned the three exporters and Program.cs)

No Semgrep or CodeQL query covers CWE-1236 for C#; the tool cannot know which strings a spreadsheet will open, nor whether CsvHelper's InjectionOptions is set. The regression tests, which parse the CSV and check each cell, are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC#
RuntimeASP.NET Core 10 minimal API on .NET 10.0.12
FrameworkASP.NET Core 10 (minimal API)
CsvHelper33.1.0
Microsoft.AspNetCore.Mvc.Testing (test only)10.0.12
xunit.v3 (test only)4.0.2

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/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Contacts.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Contacts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Contacts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong dotnet test --project tests/Contacts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs csv-formula-injection/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 24 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • dotnetSdk 10.0.112
  • aspNetCoreRuntime 10.0.12
  • csvHelper 33.1.0
  • xunitV3 4.0.2
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 four functional tests because it does not quote at all); IMPL=wrong 2/6 fail (both exploit tests: CsvHelper quotes correctly for RFC 4180 but InjectionOptions defaults to None, so a leading =, +, -, @, tab or carriage return is never neutralized).

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. CsvHelper source: CsvConfiguration (InjectionOptions, InjectionCharacters, InjectionEscapeCharacter)
  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.