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:
@@ -44,4 +44,19 @@ public static class Csv
|
||||
return value;
|
||||
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defuses spreadsheet formula / DDE injection: when a cell would start with a formula
|
||||
/// trigger (<c>= + - @</c>, 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DomainEventId, string> titles, DomainEventId id) =>
|
||||
titles.TryGetValue(id, out var t) ? t : id.Value;
|
||||
Csv.NeutralizeFormula(titles.TryGetValue(id, out var t) ? t : id.Value);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user