feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固 - 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越 - LocalReportFileStorage: 文件存储路径安全加固 - local_account_file_cleanup: 账号删除时文件清理逻辑增强 - AuthService: 认证逻辑增强 - file_endpoints / report_endpoints: 文件访问接口安全加固 - ai_chat_endpoints / doctor_endpoints: 接口安全调整 - Program.cs: 服务注册调整 ## 前端认证与媒体 - 新增 authenticated_network_image.dart: 带认证的图片加载组件 - auth_provider: 认证状态管理大幅增强(+173) - api_client: 网络客户端增强(+124) - chat_provider: 聊天 provider 重构(+76) - omron_device_provider: 蓝牙设备 provider 增强(+53) - sse_handler: SSE 处理增强(+35) - consultation_provider / data_providers / conversation_history_provider: 调整 ## 页面调整 - remaining_pages: 健康档案/饮食记录等页面增强(+115) - home_page / chat_messages_view: 主页微调 - doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports) - report_pages / settings_pages / notification_prefs_page: 微调 - device_scan_page / diet_capture_page / admin_home_page: 微调 ## 测试 - 新增 file_path_security_tests: 文件路径安全测试 - 新增 protected_media_url_test: 媒体URL保护测试 - 新增 user_session_identity_test: 用户会话身份测试 - account_deletion_tests / application_service_tests / auth_tests: 更新
This commit is contained in:
@@ -9,14 +9,23 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
|
||||
|
||||
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||
{
|
||||
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||
AddMetadataUrls(fileUrls, metadataJson);
|
||||
|
||||
foreach (var fileUrl in fileUrls)
|
||||
// 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。
|
||||
foreach (var fileUrl in references.FileUrls)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var localPath = ResolveLocalPath(fileUrl);
|
||||
var localPath = ResolveReportPath(fileUrl);
|
||||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||
}
|
||||
|
||||
// 对话元数据可能包含客户端传入的 URL,只允许解析当前账号自己的目录。
|
||||
var attachmentUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||
AddMetadataUrls(attachmentUrls, metadataJson);
|
||||
|
||||
foreach (var fileUrl in attachmentUrls)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var localPath = ResolveOwnedAttachmentPath(userId, fileUrl);
|
||||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||
}
|
||||
|
||||
@@ -52,31 +61,65 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
|
||||
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
|
||||
}
|
||||
|
||||
private string? ResolveLocalPath(string fileUrl)
|
||||
private string? ResolveReportPath(string fileUrl)
|
||||
{
|
||||
var urlPath = fileUrl;
|
||||
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
|
||||
urlPath = absoluteUri.AbsolutePath;
|
||||
|
||||
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
|
||||
if (uploadsIndex < 0) return null;
|
||||
|
||||
string relativePath;
|
||||
try
|
||||
{
|
||||
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
|
||||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: fileUrl.Split('?', 2)[0];
|
||||
var prefixIndex = path.IndexOf("/uploads/reports/", StringComparison.OrdinalIgnoreCase);
|
||||
if (prefixIndex < 0) return null;
|
||||
var fileName = Uri.UnescapeDataString(path[(prefixIndex + "/uploads/reports/".Length)..]);
|
||||
return ResolveInside(Path.Combine(_uploadsRoot, "reports"), fileName);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var queryIndex = relativePath.IndexOfAny(['?', '#']);
|
||||
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
|
||||
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: fileUrl.Split('?', 2)[0];
|
||||
path = Uri.UnescapeDataString(path);
|
||||
var protectedPrefix = "/api/files/content/";
|
||||
var protectedIndex = path.IndexOf(protectedPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
var legacyPrefix = $"/uploads/users/{userId:N}/";
|
||||
var legacyIndex = path.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
var fileName = protectedIndex >= 0
|
||||
? path[(protectedIndex + protectedPrefix.Length)..]
|
||||
: legacyIndex >= 0
|
||||
? path[(legacyIndex + legacyPrefix.Length)..]
|
||||
: null;
|
||||
return fileName == null
|
||||
? null
|
||||
: ResolveInside(Path.Combine(_uploadsRoot, "users", userId.ToString("N")), fileName);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
|
||||
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
private static string? ResolveInside(string root, string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
|
||||
var fullRoot = Path.GetFullPath(root);
|
||||
var fullPath = Path.GetFullPath(Path.Combine(fullRoot, fileName));
|
||||
var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user