diff --git a/src/Marathon.Application/Export/Csv.cs b/src/Marathon.Application/Export/Csv.cs
index 6733c26..1bae1d8 100644
--- a/src/Marathon.Application/Export/Csv.cs
+++ b/src/Marathon.Application/Export/Csv.cs
@@ -44,4 +44,19 @@ public static class Csv
return value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
+
+ ///
+ /// Defuses spreadsheet formula / DDE injection: when a cell would start with a formula
+ /// trigger (= + - @, tab or CR) it is prefixed with an apostrophe so Excel /
+ /// LibreOffice render it as text. Apply to USER-supplied or SCRAPED text fields (notes,
+ /// event titles) before they enter a row — numeric/date cells your own code formats are
+ /// trusted and don't need it (keeping them numeric for analysis).
+ ///
+ public static string NeutralizeFormula(string? field)
+ {
+ var value = field ?? string.Empty;
+ return value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r'
+ ? "'" + value
+ : value;
+ }
}
diff --git a/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs b/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs
index 97444ff..f5caad8 100644
--- a/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs
+++ b/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs
@@ -60,7 +60,7 @@ public sealed class ExportToCsvUseCase
{
b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
Title(titles, b.EventId),
- b.EventId.Value,
+ Csv.NeutralizeFormula(b.EventId.Value),
b.Selection.Type.ToString(),
b.Selection.Side.ToString(),
b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
@@ -68,7 +68,7 @@ public sealed class ExportToCsvUseCase
b.Stake.ToString(CultureInfo.InvariantCulture),
b.Outcome.ToString(),
b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
- b.Notes ?? string.Empty,
+ Csv.NeutralizeFormula(b.Notes),
});
return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false);
@@ -93,7 +93,7 @@ public sealed class ExportToCsvUseCase
{
b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
Title(titles, b.EventId),
- b.EventId.Value,
+ Csv.NeutralizeFormula(b.EventId.Value),
b.PickedSide.ToString(),
b.Rate.ToString(CultureInfo.InvariantCulture),
b.Stake.ToString(CultureInfo.InvariantCulture),
@@ -127,6 +127,8 @@ public sealed class ExportToCsvUseCase
return titles;
}
+ // Titles are scraped ("Side1 vs Side2") so they're treated as untrusted text and
+ // neutralized against CSV/formula injection.
private static string Title(IReadOnlyDictionary titles, DomainEventId id) =>
- titles.TryGetValue(id, out var t) ? t : id.Value;
+ Csv.NeutralizeFormula(titles.TryGetValue(id, out var t) ? t : id.Value);
}
diff --git a/tests/Marathon.Application.Tests/Export/CsvTests.cs b/tests/Marathon.Application.Tests/Export/CsvTests.cs
index 1d31fac..e5c5429 100644
--- a/tests/Marathon.Application.Tests/Export/CsvTests.cs
+++ b/tests/Marathon.Application.Tests/Export/CsvTests.cs
@@ -38,4 +38,32 @@ public sealed class CsvTests
"Kelly,ok\r\n" +
"\"Flat, fixed\",\"say \"\"hi\"\"\"\r\n");
}
+
+ [Theory]
+ [InlineData("=cmd|'/c calc'!A1")]
+ [InlineData("+1+1")]
+ [InlineData("-2+3")]
+ [InlineData("@SUM(A1)")]
+ [InlineData("\ttab")]
+ [InlineData("\rcr")]
+ public void NeutralizeFormula_PrefixesLeadingFormulaTriggers(string dangerous)
+ {
+ Csv.NeutralizeFormula(dangerous).Should().Be("'" + dangerous);
+ }
+
+ [Theory]
+ [InlineData("Home vs Away")]
+ [InlineData("normal note")]
+ [InlineData("3-1 win")]
+ [InlineData("")]
+ public void NeutralizeFormula_LeavesSafeValuesUntouched(string safe)
+ {
+ Csv.NeutralizeFormula(safe).Should().Be(safe);
+ }
+
+ [Fact]
+ public void NeutralizeFormula_Null_IsEmpty()
+ {
+ Csv.NeutralizeFormula(null).Should().BeEmpty();
+ }
}
diff --git a/tests/Marathon.Application.Tests/UseCases/ExportToCsvUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/ExportToCsvUseCaseTests.cs
index 719a431..75a2e6a 100644
--- a/tests/Marathon.Application.Tests/UseCases/ExportToCsvUseCaseTests.cs
+++ b/tests/Marathon.Application.Tests/UseCases/ExportToCsvUseCaseTests.cs
@@ -74,6 +74,27 @@ public sealed class ExportToCsvUseCaseTests : IDisposable
content.Should().Contain("evt-1");
}
+ [Fact]
+ public async Task ExportJournalAsync_NeutralizesFormulaInjection_InNotes()
+ {
+ var bet = new PlacedBet(
+ Guid.NewGuid(),
+ new EventId("evt-1"),
+ new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.0m)),
+ 100m, T0, BetOutcome.Pending, "=cmd|'/c calc'!A1");
+ _bets.ListAsync(Arg.Any()).Returns(new[] { bet });
+ _events.GetManyAsync(Arg.Any>(), Arg.Any())
+ .Returns(new Dictionary());
+
+ var path = await CreateSut().ExportJournalAsync();
+
+ var content = await File.ReadAllTextAsync(path!);
+ // The dangerous note is apostrophe-prefixed so no cell opens as a live formula in
+ // Excel; it sits in the last column, preceded by the CSV separator.
+ content.Should().Contain(",'=cmd");
+ content.Should().NotContain(",=cmd");
+ }
+
[Fact]
public async Task ExportPaperLedgerAsync_ReturnsNull_When_NoBets()
{