fix: 前端深度审计全量修复 — 安全/功能/代码质量
严重 BUG 修复: - 修复 Token 过期后 hash 重定向导致无法跳转登录页 - 修复文章编辑器新建后提交审核使用错误 ID 安全加固: - HTML 清理函数替换为 ammonia 专业库(替代自定义解析器) - 文件上传添加 magic bytes 校验(防 Content-Type 伪造) - 登录添加账户级失败锁定(5次失败→15分钟锁定) - 审计日志 9 个关键更新操作补充变更前后值(with_changes) 功能缺陷修复: - 登录/登出时清理 API 缓存(防多账户数据污染) - 文章编辑器上传改用统一 HTTP 客户端(自动 token 刷新) - 添加全局 HTTP 错误处理和后端错误消息展示 - PrivateRoute 增加路由级权限检查(系统管理页面) - 健康数据三个 Tab 添加编辑/删除功能 - 预约创建增加排班可用性校验提示 - 医生详情 API 返回解密后的原始执照号 代码清理: - 删除未使用的 auth.ts refresh() 函数 - 删除重复的 AuthGuard.tsx 组件 - 删除未使用的 getHealthSummary API
This commit is contained in:
@@ -82,6 +82,9 @@ where
|
||||
)));
|
||||
}
|
||||
|
||||
// 校验 magic bytes:验证文件实际内容与声明的 Content-Type 一致
|
||||
validate_magic_bytes(&content_type, &data)?;
|
||||
|
||||
// 生成唯一文件名,保留原始扩展名
|
||||
let ext = std::path::Path::new(&original_name)
|
||||
.extension()
|
||||
@@ -137,6 +140,78 @@ fn validate_content_type(content_type: &str) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验文件 magic bytes(文件签名)与声明的 Content-Type 是否一致。
|
||||
///
|
||||
/// 防止攻击者通过修改 Content-Type 头上传恶意文件。
|
||||
/// 对于 Office 格式等复杂签名,跳过 magic bytes 校验(仅依赖白名单)。
|
||||
fn validate_magic_bytes(content_type: &str, data: &[u8]) -> Result<(), AppError> {
|
||||
// 需要至少几个字节才能校验
|
||||
if data.is_empty() {
|
||||
return Err(AppError::Validation("文件内容为空".to_string()));
|
||||
}
|
||||
|
||||
let signature: &[u8] = match content_type {
|
||||
"image/jpeg" => {
|
||||
// JPEG: FF D8 FF
|
||||
b"\xFF\xD8\xFF"
|
||||
}
|
||||
"image/png" => {
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
b"\x89PNG\r\n\x1A\n"
|
||||
}
|
||||
"image/gif" => {
|
||||
// GIF: 47 49 46 38 (GIF8)
|
||||
b"GIF8"
|
||||
}
|
||||
"image/webp" => {
|
||||
// WebP: RIFF....WEBP (12 bytes)
|
||||
// 前 4 字节: 52 49 46 46 (RIFF)
|
||||
// 字节 8-11: 57 45 42 50 (WEBP)
|
||||
if data.len() < 12 {
|
||||
return Err(AppError::Validation(
|
||||
"文件数据不足,无法验证 WebP 格式".to_string(),
|
||||
));
|
||||
}
|
||||
let riff_ok = &data[0..4] == b"RIFF";
|
||||
let webp_ok = &data[8..12] == b"WEBP";
|
||||
if riff_ok && webp_ok {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(AppError::Validation(
|
||||
"文件内容与声明的类型 (image/webp) 不匹配".to_string(),
|
||||
));
|
||||
}
|
||||
"application/pdf" => {
|
||||
// PDF: 25 50 44 46 (%PDF)
|
||||
b"%PDF"
|
||||
}
|
||||
// Office 格式的 magic bytes 较复杂(OLE2 / ZIP-based OOXML),
|
||||
// 仅依赖白名单,跳过 magic bytes 校验
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
|
||||
return Ok(());
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
if data.len() < signature.len() {
|
||||
return Err(AppError::Validation(
|
||||
"文件数据不足,无法验证文件格式".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if &data[..signature.len()] != signature {
|
||||
return Err(AppError::Validation(format!(
|
||||
"文件内容与声明的类型 ({}) 不匹配",
|
||||
content_type
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes >= 1024 * 1024 * 1024 {
|
||||
format!("{}GB", bytes / (1024 * 1024 * 1024))
|
||||
|
||||
Reference in New Issue
Block a user