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.
- Find every export that writes user-entered data to CSV or anything a spreadsheet opens.
- 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.
- 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.
- 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.
- Serve the file 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
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
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
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
// 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 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.
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}");
}
}- 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.
- 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).
- 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.
- 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/csharp | 0 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.
| Component | Version |
|---|---|
| Language | C# |
| Runtime | ASP.NET Core 10 minimal API on .NET 10.0.12 |
| Framework | ASP.NET Core 10 (minimal API) |
| CsvHelper | 33.1.0 |
| Microsoft.AspNetCore.Mvc.Testing (test only) | 10.0.12 |
| xunit.v3 (test only) | 4.0.2 |
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.
dotnet restore tests/Contacts.Tests --locked-mode# 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.xmlEach 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.
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.