fix: 前端深度审计全量修复 — 安全/功能/代码质量
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

严重 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:
iven
2026-04-26 21:47:26 +08:00
parent f0c3426792
commit 787e64d9a9
23 changed files with 1152 additions and 482 deletions

View File

@@ -1,44 +1,36 @@
/// HTML/Script 内容清理工具。
///
/// 在用户输入进入数据库之前,剥离所有 HTML 标签,防止存储型 XSS。
/// 基于 ammoniahtml5ever剥离所有 HTML 标签,防止存储型 XSS。
/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。
/// 剥离字符串中的所有 HTML 标签,返回纯文本。
///
/// ```rust
/// use erp_core::sanitize::strip_html_tags;
/// assert_eq!(strip_html_tags("<script>alert(1)</script>"), "alert(1)");
/// assert_eq!(strip_html_tags("<img src=x onerror=alert(1)>"), "");
/// assert_eq!(strip_html_tags("Hello <b>World</b>"), "Hello World");
/// ```
/// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。
/// 比手写字符级解析器更安全,能正确处理所有 HTML 边界情况。
pub fn strip_html_tags(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut in_tag = false;
let mut depth = 0usize;
// 使用 ammonia 清理(保留在 span 中的纯文本),然后剥离 span 标签
let doc = ammonia::Builder::new()
.tags(std::collections::HashSet::new())
.clean(input)
.to_string();
for ch in input.chars() {
match ch {
'<' => {
in_tag = true;
depth += 1;
}
'>' => {
if depth > 0 {
depth -= 1;
}
if depth == 0 {
in_tag = false;
}
}
_ => {
if !in_tag {
result.push(ch);
}
}
}
}
// ammonia 的 clean() 结果可能包含 HTML 实体(如 &lt;),需要解码
// 但由于所有标签已被禁止,结果是纯文本(可能有实体转义)
// 使用二次清理:将结果作为纯文本处理
decode_entities(&doc).trim().to_string()
}
result.trim().to_string()
/// 简单解码常见 HTML 实体。
fn decode_entities(input: &str) -> String {
input
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&#47;", "/")
.replace("&#32;", " ")
}
/// 对 Option<String> 类型的字段进行清理。
@@ -57,7 +49,8 @@ mod tests {
#[test]
fn strips_script_tag() {
assert_eq!(strip_html_tags("<script>alert('xss')</script>"), "alert('xss')");
// script 内容在 HTML 规范中是 raw textammonia 正确地将其完全移除
assert_eq!(strip_html_tags("<script>alert('xss')</script>"), "");
}
#[test]
@@ -83,7 +76,7 @@ mod tests {
#[test]
fn sanitize_option_some() {
assert_eq!(
sanitize_option(Some("<script>evil</script>".to_string())),
sanitize_option(Some("<b>evil</b>".to_string())),
Some("evil".to_string())
);
}
@@ -97,4 +90,22 @@ mod tests {
fn sanitize_option_becomes_empty() {
assert_eq!(sanitize_option(Some("<img>".to_string())), None);
}
#[test]
fn strips_nested_script_attack() {
let result = strip_html_tags("<scr<script>ipt>alert(1)</scr</script>ipt>");
assert!(!result.contains("<"), "不应残留 HTML 标签");
}
#[test]
fn strips_unclosed_tag() {
let result = strip_html_tags("text <img");
assert!(result.contains("text") || result.is_empty());
}
#[test]
fn handles_entities() {
let result = strip_html_tags("a &lt; b");
assert!(result.contains("a") && result.contains("b"));
}
}