Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,107 @@ public void GenerateDefaultRecordingFilename_UsesGregorianCalendar_UnderThaiCult
});
}

[Fact]
public void RecordValue_NeutralizesFormulaInjection_InDisplayName()
{
// Arrange - DisplayName originates from the (potentially untrusted)
// OPC UA server. "=cmd|'/C calc'!A0" executes as a formula when the
// CSV is opened in Excel/LibreOffice (CWE-1236).
var filePath = Path.Combine(_testDirectory, "test.csv");
_manager.StartRecording(filePath);
var node = new MonitoredNode
{
DisplayName = "=cmd|'/C calc'!A0",
NodeId = new NodeId(1234),
Value = "100"
};

// Act
_manager.RecordValue(node);
_manager.StopRecording(); // waits for the background writer to drain

// Assert - the field must be prefixed with a single quote so
// spreadsheets treat it as text.
var content = File.ReadAllText(filePath);
Assert.Contains(",'=cmd|'/C calc'!A0,", content);
Assert.DoesNotContain(",=cmd", content);
}

[Fact]
public void RecordValue_DoesNotNeutralizeNegativeNumericValue()
{
// Arrange - recorded values are routinely negative numbers; they are
// inert in spreadsheets and prefixing them would corrupt the data
// column for downstream tools.
var filePath = Path.Combine(_testDirectory, "test.csv");
_manager.StartRecording(filePath);
var node = new MonitoredNode
{
DisplayName = "TestNode",
NodeId = new NodeId(1234),
Value = "-12.5",
RawValue = "-12.5"
};

// Act
_manager.RecordValue(node);
_manager.StopRecording();

// Assert - written verbatim, no neutralization prefix.
var content = File.ReadAllText(filePath);
Assert.Contains(",-12.5,", content);
Assert.DoesNotContain("'-12.5", content);
}

[Fact]
public void RecordValue_NeutralizesPlusPrefixedDisplayName()
{
// Arrange - "+SomeTag" starts with a formula trigger and is not a
// valid invariant-culture number, so it must be neutralized.
var filePath = Path.Combine(_testDirectory, "test.csv");
_manager.StartRecording(filePath);
var node = new MonitoredNode
{
DisplayName = "+SomeTag",
NodeId = new NodeId(1234),
Value = "100"
};

// Act
_manager.RecordValue(node);
_manager.StopRecording();

// Assert
var content = File.ReadAllText(filePath);
Assert.Contains(",'+SomeTag,", content);
Assert.DoesNotContain(",+SomeTag,", content);
}

[Fact]
public void RecordValue_CombinesNeutralizationWithRfc4180Quoting()
{
// Arrange - a field that both starts with a formula trigger and
// contains a comma must be neutralized AND wrapped in quotes.
var filePath = Path.Combine(_testDirectory, "test.csv");
_manager.StartRecording(filePath);
var node = new MonitoredNode
{
DisplayName = "=HYPERLINK(\"http://evil\",\"click\")",
NodeId = new NodeId(1234),
Value = "=1+2,cmd"
};

// Act
_manager.RecordValue(node);
_manager.StopRecording();

// Assert - neutralizing quote prefix inside the RFC 4180 quoted field,
// with internal quotes doubled.
var content = File.ReadAllText(filePath);
Assert.Contains("\"'=HYPERLINK(\"\"http://evil\"\",\"\"click\"\")\"", content);
Assert.Contains("\"'=1+2,cmd\"", content);
}

[Fact]
public void StartRecording_ClearsStaleQueueFromPreviousSession()
{
Expand Down
41 changes: 40 additions & 1 deletion Utilities/CsvRecordingManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,10 @@ private void WriteRecord(RecordSnapshot item)
var timestamp = item.Timestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture)
?? DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture);

// Escape values for CSV (handle quotes and commas)
// Escape values for CSV (RFC 4180 quoting plus formula
// injection neutralization for server-supplied fields).
// The timestamp is generated locally in a fixed format, so it
// needs no escaping; the header line is a constant.
var displayName = EscapeCsvField(item.DisplayName);
var nodeId = EscapeCsvField(item.NodeId);
var value = EscapeCsvField(item.Value);
Expand Down Expand Up @@ -511,6 +514,10 @@ private static string EscapeCsvField(string field)
return field;
}

// Neutralize spreadsheet formula injection before applying RFC 4180
// quoting, so the quoting decision sees the final field content.
field = NeutralizeFormulaInjection(field);

// If field contains comma, quote, or newline, wrap in quotes and escape internal quotes
if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r'))
{
Expand All @@ -520,6 +527,38 @@ private static string EscapeCsvField(string field)
return field;
}

/// <summary>
/// Neutralizes spreadsheet formula injection (CWE-1236). DisplayName,
/// NodeId, Value and Status originate from the OPC UA server, which is
/// potentially untrusted on a plant network; a field such as
/// "=cmd|'/C calc'!A0" executes as a formula when the CSV is opened in
/// Excel/LibreOffice (RFC 4180 quoting alone does not prevent this).
/// Fields starting with a formula trigger character ('=', '+', '-', '@',
/// tab, or CR) are prefixed with a single quote, which spreadsheets
/// interpret as "treat as text".
/// Exception: fields that parse as a number under InvariantCulture are
/// NOT neutralized - recorded values are routinely negative numbers
/// (e.g. "-12.5"), they are inert in spreadsheets, and prefixing them
/// would corrupt the data column for downstream tools.
/// </summary>
private static string NeutralizeFormulaInjection(string field)
{
var first = field[0];
if (first is not ('=' or '+' or '-' or '@' or '\t' or '\r'))
{
return field;
}

// Valid invariant-culture numbers (covers leading '+'/'-') are safe
// and must round-trip unchanged.
if (double.TryParse(field, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
{
return field;
}

return "'" + field;
}

public void Dispose()
{
StopRecording();
Expand Down
Loading