-
Notifications
You must be signed in to change notification settings - Fork 0
Fix adversarial review findings for CSV export #794
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,10 +39,13 @@ public async Task<Result<MetricsExportResult>> ExportCsvAsync( | |
| var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); | ||
| var fileName = $"board-metrics-{metrics.BoardId:N}-{timestamp}.csv"; | ||
|
|
||
| return Result.Success(new MetricsExportResult( | ||
| Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray(), | ||
| fileName, | ||
| "text/csv")); | ||
| var bom = Encoding.UTF8.GetPreamble(); | ||
| var csvBytes = Encoding.UTF8.GetBytes(csv); | ||
| var content = new byte[bom.Length + csvBytes.Length]; | ||
| bom.CopyTo(content, 0); | ||
| csvBytes.CopyTo(content, bom.Length); | ||
|
|
||
| return Result.Success(new MetricsExportResult(content, fileName, "text/csv")); | ||
| } | ||
|
|
||
| internal static string BuildCsv(BoardMetricsResponse metrics) | ||
|
|
@@ -106,7 +109,8 @@ internal static string BuildCsv(BoardMetricsResponse metrics) | |
|
|
||
| /// <summary> | ||
| /// Sanitize a field for safe CSV inclusion. | ||
| /// - Strips CSV injection characters (=, +, -, @, tab, carriage return) from the start. | ||
| /// - Strips CSV injection characters (=, +, -, @, tab, carriage return) from the start | ||
| /// of the value AND from the start of each embedded line (after \n or \r\n). | ||
| /// - Quotes the field if it contains commas, quotes, or newlines. | ||
| /// - Doubles internal quote characters. | ||
| /// </summary> | ||
|
|
@@ -115,12 +119,11 @@ internal static string SanitizeCsvField(string value) | |
| if (string.IsNullOrEmpty(value)) | ||
| return value; | ||
|
|
||
| // Strip leading characters that could trigger formula injection in spreadsheet apps | ||
| var sanitized = value; | ||
| while (sanitized.Length > 0 && IsDangerousLeadingChar(sanitized[0])) | ||
| { | ||
| sanitized = sanitized[1..]; | ||
| } | ||
| // Strip leading characters that could trigger formula injection in spreadsheet apps. | ||
| // Apply to each line within the value to prevent injection via embedded newlines | ||
| // (e.g. "hello\n=CMD|'/C calc'!A0" must sanitize the second line too). | ||
| var sanitized = StripDangerousLeadingChars(value); | ||
| sanitized = SanitizeEmbeddedLines(sanitized); | ||
|
|
||
| // If the field contains special CSV characters, quote it | ||
| var needsQuoting = sanitized.Contains(',') || | ||
|
|
@@ -136,6 +139,35 @@ internal static string SanitizeCsvField(string value) | |
| return sanitized; | ||
| } | ||
|
|
||
| private static string StripDangerousLeadingChars(string s) | ||
| { | ||
| var i = 0; | ||
| while (i < s.Length && IsDangerousLeadingChar(s[i])) | ||
| i++; | ||
| return i == 0 ? s : s[i..]; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// For each line after the first within a multi-line value, strip leading | ||
| /// dangerous characters so that embedded newlines cannot smuggle formula prefixes. | ||
| /// </summary> | ||
| private static string SanitizeEmbeddedLines(string value) | ||
| { | ||
| if (!value.Contains('\n') && !value.Contains('\r')) | ||
| return value; | ||
|
|
||
| var lines = value.Split('\n'); | ||
| for (var i = 1; i < lines.Length; i++) | ||
| { | ||
| var line = lines[i]; | ||
| // Handle \r\n: the \r will be at the end of the previous line's split, | ||
| // but also strip dangerous chars that appear after a bare \n. | ||
| lines[i] = StripDangerousLeadingChars(line); | ||
| } | ||
|
|
||
| return string.Join('\n', lines); | ||
| } | ||
|
Comment on lines
+154
to
+169
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The private static string SanitizeEmbeddedLines(string value)
{
if (!value.Contains('\n') && !value.Contains('\r'))
return value;
// Normalize line endings to \n to ensure all embedded lines are caught,
// then strip dangerous characters from the start of each line (except the first,
// which is handled by the caller).
var lines = value.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
for (var i = 1; i < lines.Length; i++)
{
lines[i] = StripDangerousLeadingChars(lines[i]);
}
return string.Join('\n', lines);
} |
||
|
|
||
| private static bool IsDangerousLeadingChar(char c) | ||
| => c is '=' or '+' or '-' or '@' or '\t' or '\r'; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SanitizeEmbeddedLines() checks for both '\n' and '\r' but only splits on '\n'. If an attacker uses a bare carriage return as the embedded line break (e.g. "safe\r=CMD(...)"), the second line is never sanitized and the formula prefix can remain. Consider sanitizing after any newline boundary (handle '\r' as a delimiter too, including '\r\n'), and add a unit test covering the '\r' (no '\n') case.