fix(export): neutralize CSV/DDE formula injection in exported text

Exported journal notes and scraped event titles could begin with a formula
trigger (= + - @, tab, CR) that Excel/LibreOffice execute on open.
Csv.NeutralizeFormula apostrophe-prefixes such cells so they render as text;
applied to user notes, raw event ids and scraped titles. Numeric/date cells
the exporter formats itself stay numeric for downstream analysis.
This commit is contained in:
2026-05-29 13:57:18 +03:00
parent 88615a95e9
commit 08486667c3
4 changed files with 70 additions and 4 deletions
@@ -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<CancellationToken>()).Returns(new[] { bet });
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<EventId, Event>());
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()
{