From ab05c01f7671a0d9624469e30a9dd41306b536f2 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 2 Jul 2026 17:37:07 +0800 Subject: [PATCH] =?UTF-8?q?test(api/database):=20=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=A1=A5=E5=8D=95=E6=B5=8B=20+=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20backup=5Fpath=20=E8=B7=AF=E5=BE=84=E7=A9=BF?= =?UTF-8?q?=E8=B6=8A=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backup.rs 是恢复流程的安全关键路径,此前无任何单测。本次: 提取纯函数(便于单测,无行为变更): - is_valid_backup_filename:文件名白名单校验,替换三处内联 Regex - parse_backup_mode:从 -- mode: 行提取模式值 - has_valid_signature:首行签名头校验 新增 15 个单测,覆盖: - 文件名白名单(正常/路径穿越/特殊字符三类边界) - backup_path 路径穿越纵深防御 - 签名校验(精确/前导空白/非系统文件/空内容) - 模式解析(pg_dump/sql-fallback/缺失/空值/多次) 修复一个真实的路径穿越漏洞(测试驱动发现): backup_path("/etc/passwd") 之前返回 /etc/passwd 而非 backups/—— 因为 [BACKUP_DIR, filename].collect::() 在 filename 为绝对 路径时会丢弃 BACKUP_DIR 前缀(PathBuf 语义),随后的 components 检查 在错位的路径上运行而漏判。改为先校验 filename 自身的 components (只允许 Normal 段),从根上杜绝穿越。 注:第一道白名单 is_valid_backup_filename 已拒绝含 / 的文件名, 故该漏洞实际不可达;但纵深防御必须健壮,本修复使其名副其实。 --- src/api/database/backup.rs | 235 +++++++++++++++++++++++++++++++++---- 1 file changed, 211 insertions(+), 24 deletions(-) diff --git a/src/api/database/backup.rs b/src/api/database/backup.rs index ffd09a9..23ca7e5 100644 --- a/src/api/database/backup.rs +++ b/src/api/database/backup.rs @@ -337,8 +337,7 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result Result Result, ServerFnError> { Err(_) => continue, }; let mode = std::fs::read_to_string(entry.path()) - .ok() - .and_then(|s| { - s.lines() - .find(|l| l.starts_with("-- mode:")) - .map(|l| l.trim_start_matches("-- mode: ").trim().to_string()) - }) - .unwrap_or_else(|| "unknown".to_string()); + .map(|s| parse_backup_mode(&s)) + .unwrap_or_else(|_| "unknown".to_string()); let created_at = meta .modified() .ok() @@ -510,8 +501,7 @@ pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> { let _user = get_current_admin_user().await?; #[cfg(feature = "server")] { - let re = regex::Regex::new(FILENAME_RE).unwrap(); - if !re.is_match(&filename) { + if !is_valid_backup_filename(&filename) { return Err(AppError::BadRequest("无效的文件名".to_string()).into()); } let path = backup_path(&filename); @@ -528,19 +518,66 @@ pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> { } /// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。 +/// +/// 纵深防御:即便第一道白名单 `is_valid_backup_filename` 被绕过,这里也要 +/// 保证结果不逃出 BACKUP_DIR。直接对 filename 做 components 检查—— +/// 含 `..`(ParentDir)、绝对路径前缀(RootDir/Prefix,如 `/etc` 或 `C:\`) +/// 的 filename 一律降级为 BACKUP_DIR 本身。 +/// +/// 注意:不能用 `[BACKUP_DIR, filename].collect::()` 后再检—— +/// 当 filename 是绝对路径时,PathBuf 语义会丢弃 BACKUP_DIR 前缀(如 +/// `["backups", "/etc/passwd"]` → `/etc/passwd`),导致 components 检查 +/// 在错位的路径上运行而漏判。必须先检 filename 本身。 #[cfg(feature = "server")] fn backup_path(filename: &str) -> PathBuf { - let raw: PathBuf = [BACKUP_DIR, filename].iter().collect(); - // 确保规范化后首两段仍是 BACKUP_DIR/filename(防任何路径穿越残留) - let mut it = raw.components(); - let _ = it.next(); // BACKUP_DIR - if it.all(|c| !matches!(c, Component::ParentDir | Component::RootDir)) { - raw + // 直接检查 filename 的 components:只允许 Normal 段。 + let filename_is_safe = std::path::Path::new(filename) + .components() + .all(|c| matches!(c, Component::Normal(_))); + if filename_is_safe { + let mut p = PathBuf::from(BACKUP_DIR); + p.push(filename); + p } else { + // 命中 ParentDir/RootDir/Prefix/CurDir → 降级为 BACKUP_DIR PathBuf::from(BACKUP_DIR) } } +/// 校验备份文件名是否符合白名单(仅字母数字下划线点连字符)。 +/// 返回 true 表示安全可用。提取为纯函数便于单测覆盖路径穿越边界。 +#[cfg(feature = "server")] +fn is_valid_backup_filename(filename: &str) -> bool { + // regex::Regex::new 在 FILENAME_RE 是常量正则,编译期可验证不会 panic。 + regex::Regex::new(FILENAME_RE) + .map(|re| re.is_match(filename)) + .unwrap_or(false) +} + +/// 从备份文件全文提取 `-- mode: ` 行的值(如 "pg_dump"/"sql-fallback")。 +/// 提取为纯函数:把文件内容作为参数传入,便于单测。 +/// 缺失或格式不符返回 "unknown"。 +#[cfg(feature = "server")] +fn parse_backup_mode(content: &str) -> String { + content + .lines() + .find(|l| l.starts_with("-- mode:")) + .map(|l| l.trim_start_matches("-- mode:").trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// 校验备份文件首行是否含本系统签名头。 +/// 提取为纯函数:把首行(或全文)作为参数传入,便于单测。 +#[cfg(feature = "server")] +fn has_valid_signature(content: &str) -> bool { + content + .lines() + .next() + .map(|l| l.trim().contains(BACKUP_SIGNATURE)) + .unwrap_or(false) +} + /// Axum 处理器:下载备份文件(admin 鉴权 + 路径白名单)。 /// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。 #[cfg(feature = "server")] @@ -569,8 +606,7 @@ pub async fn download_backup( } // 路径白名单 - let re = regex::Regex::new(FILENAME_RE).unwrap(); - if !re.is_match(&filename) { + if !is_valid_backup_filename(&filename) { return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string())); } let path = backup_path(&filename); @@ -594,3 +630,154 @@ pub async fn download_backup( axum::body::Body::from(bytes), )) } + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + + // ── is_valid_backup_filename:文件名白名单(路径穿越第一道防线) ── + + #[test] + fn filename_accepts_normal_names() { + for name in [ + "backup_20260702_120000.sql", + "backup_20260702_120000_sqlfallback.sql", + "a.sql", + "A-B_C.123", + ] { + assert!( + is_valid_backup_filename(name), + "正常文件名应通过: {name}" + ); + } + } + + #[test] + fn filename_rejects_path_traversal() { + // 路径穿越:白名单只允许字母数字下划线点连字符,/ 和 .. 都应被拒。 + for evil in [ + "../etc/passwd", + "..\\windows\\win.ini", + "/etc/passwd", + "a/../../b", + "backup.sql/../../etc", + ] { + assert!( + !is_valid_backup_filename(evil), + "路径穿越应被拒: {evil}" + ); + } + } + + #[test] + fn filename_rejects_spaces_and_special_chars() { + // 空格、中文、shell 元字符等都不在白名单。 + for evil in [ + "backup with space.sql", + "备份.sql", + "a;rm -rf.sql", + r"a\$b.sql", + "a`b`.sql", + "", + ] { + assert!( + !is_valid_backup_filename(evil), + "特殊字符应被拒: {evil:?}" + ); + } + } + + // ── backup_path:路径穿越纵深防御(白名单之外的二次防御) ──────── + + #[test] + fn backup_path_stays_in_backup_dir_for_normal_name() { + let p = backup_path("backup_20260702.sql"); + assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下"); + assert_eq!(p.file_name().and_then(|n| n.to_str()), Some("backup_20260702.sql")); + } + + #[test] + fn backup_path_collapses_traversal_to_backup_dir() { + // 即便绕过白名单调用 backup_path(纵深防御),../ 也应被规约回 BACKUP_DIR, + // 而非指向 backups/ 之外。Component::ParentDir / RootDir 命中即降级。 + for evil in ["../etc/passwd", "../../etc/shadow"] { + let p = backup_path(evil); + // 不应逃出 BACKUP_DIR(应为 BACKUP_DIR 本身,不含文件名) + assert_eq!( + p, PathBuf::from(BACKUP_DIR), + "穿越应被规约回 {BACKUP_DIR}: {evil}" + ); + } + } + + #[test] + fn backup_path_rejects_absolute_path() { + // Component::RootDir 命中也应降级。 + let p = backup_path("/etc/passwd"); + assert_eq!(p, PathBuf::from(BACKUP_DIR)); + } + + // ── has_valid_signature:备份签名校验(拒绝非本系统文件) ─────── + + #[test] + fn signature_matches_exact_header() { + let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\nSELECT 1;\n"; + assert!(has_valid_signature(content)); + } + + #[test] + fn signature_matches_with_leading_whitespace() { + // 首行允许前导空白(trim 后匹配),容忍编辑器缩进。 + let content = " -- YGGDRASIL BACKUP v1\nrest\n"; + assert!(has_valid_signature(content)); + } + + #[test] + fn signature_rejects_non_system_file() { + // 普通 SQL 文件首行不含签名 → 拒绝恢复(防任意文件读取/执行)。 + let content = "SELECT * FROM users;\n-- YGGDRASIL BACKUP v1\n"; + // 注意:签名必须在首行。第二行有签名不算。 + assert!(!has_valid_signature(content)); + } + + #[test] + fn signature_rejects_empty_and_garbage() { + assert!(!has_valid_signature("")); + assert!(!has_valid_signature("garbage\n")); + assert!(!has_valid_signature("\n\n-- YGGDRASIL BACKUP v1")); + } + + // ── parse_backup_mode:模式解析(列表展示用) ─────────────────── + + #[test] + fn parse_mode_pg_dump() { + let content = "-- YGGDRASIL BACKUP v1\n-- mode: pg_dump\n...\n"; + assert_eq!(parse_backup_mode(content), "pg_dump"); + } + + #[test] + fn parse_mode_sql_fallback() { + let content = "-- YGGDRASIL BACKUP v1\n-- mode: sql-fallback\n\n-- table: posts\n"; + assert_eq!(parse_backup_mode(content), "sql-fallback"); + } + + #[test] + fn parse_mode_unknown_when_absent() { + let content = "-- YGGDRASIL BACKUP v1\nSELECT 1;\n"; + assert_eq!(parse_backup_mode(content), "unknown"); + } + + #[test] + fn parse_mode_unknown_when_empty_value() { + // "-- mode:" 后无值 → unknown(防空字符串显示) + let content = "-- mode:\nrest\n"; + assert_eq!(parse_backup_mode(content), "unknown"); + } + + #[test] + fn parse_mode_only_matches_first_occurrence() { + // 多个 -- mode: 行取第一个。 + let content = "-- mode: pg_dump\n-- mode: sql-fallback\n"; + assert_eq!(parse_backup_mode(content), "pg_dump"); + } +}