From d3e4b2b8deb4c2ef96f302152ac365496fb25d07 Mon Sep 17 00:00:00 2001 From: ssss <111@qq.com> Date: Fri, 3 Jul 2026 14:28:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E6=B7=BB=E5=8A=A0=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=AA=8C=E8=AF=81=E5=92=8CCSV=E8=BD=AC=E4=B9=89?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E5=AE=89=E5=85=A8=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/PathValidator.cs | 157 ++++++++++ .../Security/SecurityConstants.cs | 54 ++++ .../Services/ExportService.cs | 277 ++++++++++++++---- 3 files changed, 426 insertions(+), 62 deletions(-) create mode 100644 src/WCTDataMiner.Core/Security/PathValidator.cs create mode 100644 src/WCTDataMiner.Core/Security/SecurityConstants.cs diff --git a/src/WCTDataMiner.Core/Security/PathValidator.cs b/src/WCTDataMiner.Core/Security/PathValidator.cs new file mode 100644 index 0000000..4177175 --- /dev/null +++ b/src/WCTDataMiner.Core/Security/PathValidator.cs @@ -0,0 +1,157 @@ +using System.Text.RegularExpressions; + +namespace WCTDataMiner.Core.Security; + +/// +/// 路径验证结果 +/// +public class PathValidationResult +{ + public bool IsValid { get; } + public string? NormalizedPath { get; } + public string? ErrorMessage { get; } + + private PathValidationResult(bool isValid, string? normalizedPath, string? errorMessage) + { + IsValid = isValid; + NormalizedPath = normalizedPath; + ErrorMessage = errorMessage; + } + + public static PathValidationResult Success(string normalizedPath) + => new(true, normalizedPath, null); + + public static PathValidationResult Fail(string errorMessage) + => new(false, null, errorMessage); +} + +/// +/// 路径验证器 - 防止路径遍历攻击 +/// +public class PathValidator +{ + private readonly string _applicationBasePath; + private readonly HashSet _allowedDirectories; + + /// + /// 默认允许的输出目录 + /// + private static readonly string[] DefaultAllowedDirectories = ["exports", "output", "data/exports"]; + + public PathValidator(string applicationBasePath, IEnumerable? allowedDirectories = null) + { + _applicationBasePath = Path.GetFullPath(applicationBasePath); + _allowedDirectories = new HashSet( + (allowedDirectories ?? DefaultAllowedDirectories).Select(d => d.Replace('/', Path.DirectorySeparatorChar)), + StringComparer.OrdinalIgnoreCase); + } + + /// + /// 验证输出目录路径 + /// + /// 用户输入的输出目录路径 + /// 验证结果 + public PathValidationResult ValidateOutputDirectory(string outputDir) + { + // 1. 基础验证 + if (string.IsNullOrWhiteSpace(outputDir)) + return PathValidationResult.Fail("输出目录不能为空"); + + // 2. 禁止的模式检查 + var forbiddenPatterns = new[] + { + "..", // 路径遍历 + "~", // 用户主目录 + "\\\\", // UNC 路径 + }; + + foreach (var pattern in forbiddenPatterns) + { + if (outputDir.Contains(pattern)) + { + return PathValidationResult.Fail($"路径包含禁止的模式: {pattern}"); + } + } + + // 3. 检查是否为绝对路径(Windows 和 Unix) + if (Path.IsPathRooted(outputDir) || + Regex.IsMatch(outputDir, @"^[A-Za-z]:") || // Windows 驱动器 + outputDir.StartsWith("/")) // Unix 绝对路径 + { + return PathValidationResult.Fail("仅允许相对路径"); + } + + // 4. 规范化并验证 + string normalizedPath; + try + { + normalizedPath = Path.GetFullPath(Path.Combine(_applicationBasePath, outputDir)); + } + catch (Exception ex) + { + return PathValidationResult.Fail($"路径格式无效: {ex.Message}"); + } + + // 5. 严格验证必须在应用目录内 + if (!normalizedPath.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase)) + { + return PathValidationResult.Fail("路径必须在应用程序目录内"); + } + + // 6. 白名单检查 + var relative = normalizedPath + .Substring(_applicationBasePath.Length) + .TrimStart(Path.DirectorySeparatorChar); + + bool inAllowedDir = _allowedDirectories + .Any(allowed => relative.StartsWith(allowed + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + string.Equals(relative, allowed, StringComparison.OrdinalIgnoreCase)); + + if (!inAllowedDir) + { + var allowedList = string.Join(", ", _allowedDirectories); + return PathValidationResult.Fail($"输出目录必须在允许列表中: {allowedList}"); + } + + return PathValidationResult.Success(normalizedPath); + } + + /// + /// 简化版验证 - 仅检查路径安全性,不做白名单检查 + /// + public PathValidationResult ValidatePathSafety(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return PathValidationResult.Fail("路径不能为空"); + + // 检查路径遍历 + if (path.Contains("..")) + return PathValidationResult.Fail("路径包含非法的遍历字符"); + + // 检查绝对路径 + if (Path.IsPathRooted(path) || + Regex.IsMatch(path, @"^[A-Za-z]:") || + path.StartsWith("/")) + { + return PathValidationResult.Fail("仅允许相对路径"); + } + + // 检查 UNC 路径 + if (path.StartsWith("\\\\")) + return PathValidationResult.Fail("不允许 UNC 路径"); + + // 规范化验证 + try + { + var normalized = Path.GetFullPath(Path.Combine(_applicationBasePath, path)); + if (!normalized.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase)) + return PathValidationResult.Fail("路径超出应用程序目录范围"); + } + catch (Exception ex) + { + return PathValidationResult.Fail($"路径格式无效: {ex.Message}"); + } + + return PathValidationResult.Success(path); + } +} \ No newline at end of file diff --git a/src/WCTDataMiner.Core/Security/SecurityConstants.cs b/src/WCTDataMiner.Core/Security/SecurityConstants.cs new file mode 100644 index 0000000..3a2d8ff --- /dev/null +++ b/src/WCTDataMiner.Core/Security/SecurityConstants.cs @@ -0,0 +1,54 @@ +namespace WCTDataMiner.Core.Security; + +/// +/// 安全相关常量定义 +/// +public static class SecurityConstants +{ + /// + /// 默认允许的输出目录 + /// + public static readonly string[] DefaultAllowedExportDirectories = + [ + "exports", + "output", + "data/exports", + "data/output" + ]; + + /// + /// 路径最大长度限制 + /// + public const int MaxPathLength = 260; + + /// + /// 文件名最大长度限制 + /// + public const int MaxFileNameLength = 255; + + /// + /// 禁止的路径模式 + /// + public static readonly string[] ForbiddenPathPatterns = + [ + "..", // 路径遍历 + "~", // 用户主目录 + "\\\\", // UNC 路径开始 + "|", // 管道字符(Windows 禁止) + ">", // 重定向字符 + "<", // 重定向字符 + "*", // 通配符(用于文件名时) + "?" // 通配符(用于文件名时) + ]; + + /// + /// 安全的文件扩展名白名单 + /// + public static readonly string[] AllowedExportExtensions = + [ + ".csv", + ".json", + ".txt", + ".log" + ]; +} \ No newline at end of file diff --git a/src/WCTDataMiner.Core/Services/ExportService.cs b/src/WCTDataMiner.Core/Services/ExportService.cs index e33da3f..df95ca5 100644 --- a/src/WCTDataMiner.Core/Services/ExportService.cs +++ b/src/WCTDataMiner.Core/Services/ExportService.cs @@ -2,21 +2,27 @@ using System.Globalization; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using WCTDataMiner.Core.Data; using WCTDataMiner.Core.Models; +using WCTDataMiner.Core.Security; namespace WCTDataMiner.Core.Services; /// -/// 数据导出服务 +/// 数据导出服务 - 支持安全的路径验证和 RFC 4180 标准 CSV 转义 /// public class ExportService { private readonly WctMinerDbContext _context; + private readonly ILogger _logger; + private readonly PathValidator _pathValidator; - public ExportService(WctMinerDbContext context) + public ExportService(WctMinerDbContext context, ILogger logger) { _context = context; + _logger = logger; + _pathValidator = new PathValidator(AppDomain.CurrentDomain.BaseDirectory); } /// @@ -24,30 +30,72 @@ public class ExportService /// public async Task ExportQfodToCsvAsync(string outputDir) { - Directory.CreateDirectory(outputDir); - var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + _logger.LogInformation("开始导出 Qfod 数据为 CSV"); - var records = await _context.QfodRecords - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxPanel) - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxHardware) - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxSoftware) - .Include(r => r.Scenario) - .ThenInclude(s => s!.RxType) - .ToListAsync(); - - var sb = new StringBuilder(); - // CSV Header - sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type"); - - foreach (var r in records) + // 路径验证 + var validationResult = _pathValidator.ValidateOutputDirectory(outputDir); + if (!validationResult.IsValid) { - sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.ChargerIndex},{r.CoilIndex},{r.DeltaQ},{r.CurrentQ},{r.RawQ},{r.FodType}"); + throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}"); } - await File.WriteAllTextAsync(filePath, sb.ToString()); + var safeOutputDir = validationResult.NormalizedPath!; + Directory.CreateDirectory(safeOutputDir); + var filePath = Path.Combine(safeOutputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + + // 使用流式导出避免内存问题 + await using var writer = new StreamWriter(filePath, false, Encoding.UTF8); + + // CSV Header + await writer.WriteLineAsync("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type"); + + // 使用 Select 投影避免 N+1 查询 + var query = _context.QfodRecords + .Select(r => new + { + r.Id, + r.ScenarioId, + TxPanelName = r.Scenario!.TxPanel.Name, + TxHardwareVersion = r.Scenario.TxHardware.Version, + TxSoftwareVersion = r.Scenario.TxSoftware.Version, + RxTypeName = r.Scenario.RxType.Name, + r.Scenario.TestPurpose, + r.Scenario.TestDate, + r.Scenario.TestSequence, + r.ChargerIndex, + r.CoilIndex, + r.DeltaQ, + r.CurrentQ, + r.RawQ, + r.FodType + }) + .OrderBy(r => r.Id); + + int count = 0; + await foreach (var r in query.AsAsyncEnumerable()) + { + var line = string.Join(',', + EscapeCsvField(r.Id), + EscapeCsvField(r.ScenarioId), + EscapeCsvField(r.TxPanelName), + EscapeCsvField(r.TxHardwareVersion), + EscapeCsvField(r.TxSoftwareVersion), + EscapeCsvField(r.RxTypeName), + EscapeCsvField(r.TestPurpose), + EscapeCsvField(r.TestDate), + EscapeCsvField(r.TestSequence), + EscapeCsvField(r.ChargerIndex), + EscapeCsvField(r.CoilIndex), + EscapeCsvField(r.DeltaQ), + EscapeCsvField(r.CurrentQ), + EscapeCsvField(r.RawQ), + EscapeCsvField(r.FodType) + ); + await writer.WriteLineAsync(line); + count++; + } + + _logger.LogInformation("Qfod CSV 导出完成: {FilePath}, 共 {Count} 条记录", filePath, count); return filePath; } @@ -56,30 +104,88 @@ public class ExportService /// public async Task ExportPlossToCsvAsync(string outputDir) { - Directory.CreateDirectory(outputDir); - var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + _logger.LogInformation("开始导出 Ploss 数据为 CSV"); - var records = await _context.PlossRecords - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxPanel) - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxHardware) - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxSoftware) - .Include(r => r.Scenario) - .ThenInclude(s => s!.RxType) - .ToListAsync(); - - var sb = new StringBuilder(); - // CSV Header - sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,rx_type_field,rx_power,tx_power,vcoil,vin,isns,ploss,threshold,trigger_count,fod_result,protocol_type,pwm_duty,coil_index,margin"); - - foreach (var r in records) + // 路径验证 + var validationResult = _pathValidator.ValidateOutputDirectory(outputDir); + if (!validationResult.IsValid) { - sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.RxType},{r.RxPower},{r.TxPower},{r.Vcoil},{r.Vin},{r.Isns},{r.Ploss},{r.Threshold},{r.TriggerCount},{r.FodResult},{r.ProtocolType},{r.PwmDuty},{r.CoilIndex},{r.Margin}"); + throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}"); } - await File.WriteAllTextAsync(filePath, sb.ToString()); + var safeOutputDir = validationResult.NormalizedPath!; + Directory.CreateDirectory(safeOutputDir); + var filePath = Path.Combine(safeOutputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + + // 使用流式导出避免内存问题 + await using var writer = new StreamWriter(filePath, false, Encoding.UTF8); + + // CSV Header + await writer.WriteLineAsync("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,rx_type_field,rx_power,tx_power,vcoil,vin,isns,ploss,threshold,trigger_count,fod_result,protocol_type,pwm_duty,coil_index,margin"); + + // 使用 Select 投影避免 N+1 查询 + var query = _context.PlossRecords + .Select(r => new + { + r.Id, + r.ScenarioId, + TxPanelName = r.Scenario!.TxPanel.Name, + TxHardwareVersion = r.Scenario.TxHardware.Version, + TxSoftwareVersion = r.Scenario.TxSoftware.Version, + RxTypeName = r.Scenario.RxType.Name, + r.Scenario.TestPurpose, + r.Scenario.TestDate, + r.Scenario.TestSequence, + r.RxType, + r.RxPower, + r.TxPower, + r.Vcoil, + r.Vin, + r.Isns, + r.Ploss, + r.Threshold, + r.TriggerCount, + r.FodResult, + r.ProtocolType, + r.PwmDuty, + r.CoilIndex, + Margin = r.Threshold - r.Ploss + }) + .OrderBy(r => r.Id); + + int count = 0; + await foreach (var r in query.AsAsyncEnumerable()) + { + var line = string.Join(',', + EscapeCsvField(r.Id), + EscapeCsvField(r.ScenarioId), + EscapeCsvField(r.TxPanelName), + EscapeCsvField(r.TxHardwareVersion), + EscapeCsvField(r.TxSoftwareVersion), + EscapeCsvField(r.RxTypeName), + EscapeCsvField(r.TestPurpose), + EscapeCsvField(r.TestDate), + EscapeCsvField(r.TestSequence), + EscapeCsvField(r.RxType), + EscapeCsvField(r.RxPower), + EscapeCsvField(r.TxPower), + EscapeCsvField(r.Vcoil), + EscapeCsvField(r.Vin), + EscapeCsvField(r.Isns), + EscapeCsvField(r.Ploss), + EscapeCsvField(r.Threshold), + EscapeCsvField(r.TriggerCount), + EscapeCsvField(r.FodResult), + EscapeCsvField(r.ProtocolType), + EscapeCsvField(r.PwmDuty), + EscapeCsvField(r.CoilIndex), + EscapeCsvField(r.Margin) + ); + await writer.WriteLineAsync(line); + count++; + } + + _logger.LogInformation("Ploss CSV 导出完成: {FilePath}, 共 {Count} 条记录", filePath, count); return filePath; } @@ -88,18 +194,20 @@ public class ExportService /// public async Task ExportQfodToJsonAsync(string outputDir) { - Directory.CreateDirectory(outputDir); - var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json"); + _logger.LogInformation("开始导出 Qfod 数据为 JSON"); + + // 路径验证 + var validationResult = _pathValidator.ValidateOutputDirectory(outputDir); + if (!validationResult.IsValid) + { + throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}"); + } + + var safeOutputDir = validationResult.NormalizedPath!; + Directory.CreateDirectory(safeOutputDir); + var filePath = Path.Combine(safeOutputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json"); var records = await _context.QfodRecords - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxPanel) - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxHardware) - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxSoftware) - .Include(r => r.Scenario) - .ThenInclude(s => s!.RxType) .Select(r => new { r.Id, @@ -122,6 +230,8 @@ public class ExportService var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(filePath, json); + + _logger.LogInformation("Qfod JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count); return filePath; } @@ -130,18 +240,20 @@ public class ExportService /// public async Task ExportPlossToJsonAsync(string outputDir) { - Directory.CreateDirectory(outputDir); - var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json"); + _logger.LogInformation("开始导出 Ploss 数据为 JSON"); + + // 路径验证 + var validationResult = _pathValidator.ValidateOutputDirectory(outputDir); + if (!validationResult.IsValid) + { + throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}"); + } + + var safeOutputDir = validationResult.NormalizedPath!; + Directory.CreateDirectory(safeOutputDir); + var filePath = Path.Combine(safeOutputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json"); var records = await _context.PlossRecords - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxPanel) - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxHardware) - .Include(r => r.Scenario) - .ThenInclude(s => s!.TxSoftware) - .Include(r => r.Scenario) - .ThenInclude(s => s!.RxType) .Select(r => new { r.Id, @@ -172,6 +284,47 @@ public class ExportService var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(filePath, json); + + _logger.LogInformation("Ploss JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count); return filePath; } + + /// + /// RFC 4180 标准 CSV 字段转义 + /// + private static string EscapeCsvField(object? value) + { + var field = value switch + { + null => string.Empty, + DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss"), + DateOnly d => d.ToString("yyyy-MM-dd"), + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + + // 单次扫描检测所有特殊字符 + bool needsEscape = false; + foreach (char c in field) + { + if (c == ',' || c == '"' || c == '\n' || c == '\r') + { + needsEscape = true; + break; + } + } + + if (!needsEscape) return field; + + // 使用 StringBuilder 避免多次字符串分配 + var sb = new StringBuilder(field.Length + 10); + sb.Append('"'); + foreach (char c in field) + { + if (c == '"') sb.Append("\"\""); + else sb.Append(c); + } + sb.Append('"'); + return sb.ToString(); + } } \ No newline at end of file