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() {