test(api/database): 备份恢复补单测 + 修复 backup_path 路径穿越漏洞

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::<PathBuf>() 在 filename 为绝对
路径时会丢弃 BACKUP_DIR 前缀(PathBuf 语义),随后的 components 检查
在错位的路径上运行而漏判。改为先校验 filename 自身的 components
(只允许 Normal 段),从根上杜绝穿越。

注:第一道白名单 is_valid_backup_filename 已拒绝含 / 的文件名,
故该漏洞实际不可达;但纵深防御必须健壮,本修复使其名副其实。
This commit is contained in:
xfy 2026-07-02 17:37:07 +08:00
parent 533cabb0b5
commit ab05c01f76

View File

@ -337,8 +337,7 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, S
return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into()); return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
} }
// 路径穿越防护 // 路径穿越防护
let re = regex::Regex::new(FILENAME_RE).unwrap(); if !is_valid_backup_filename(&filename) {
if !re.is_match(&filename) {
return Err(AppError::BadRequest("无效的文件名".to_string()).into()); return Err(AppError::BadRequest("无效的文件名".to_string()).into());
} }
let path = backup_path(&filename); let path = backup_path(&filename);
@ -347,11 +346,8 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, S
} }
// 签名校验:首行需含签名 // 签名校验:首行需含签名
let head = std::fs::read_to_string(&path) let content = std::fs::read_to_string(&path).unwrap_or_default();
.ok() if !has_valid_signature(&content) {
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
.unwrap_or_default();
if !head.contains(BACKUP_SIGNATURE) {
return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into()); return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into());
} }
@ -470,13 +466,8 @@ pub async fn list_backups() -> Result<Vec<BackupInfo>, ServerFnError> {
Err(_) => continue, Err(_) => continue,
}; };
let mode = std::fs::read_to_string(entry.path()) let mode = std::fs::read_to_string(entry.path())
.ok() .map(|s| parse_backup_mode(&s))
.and_then(|s| { .unwrap_or_else(|_| "unknown".to_string());
s.lines()
.find(|l| l.starts_with("-- mode:"))
.map(|l| l.trim_start_matches("-- mode: ").trim().to_string())
})
.unwrap_or_else(|| "unknown".to_string());
let created_at = meta let created_at = meta
.modified() .modified()
.ok() .ok()
@ -510,8 +501,7 @@ pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
let _user = get_current_admin_user().await?; let _user = get_current_admin_user().await?;
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
let re = regex::Regex::new(FILENAME_RE).unwrap(); if !is_valid_backup_filename(&filename) {
if !re.is_match(&filename) {
return Err(AppError::BadRequest("无效的文件名".to_string()).into()); return Err(AppError::BadRequest("无效的文件名".to_string()).into());
} }
let path = backup_path(&filename); let path = backup_path(&filename);
@ -528,19 +518,66 @@ pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
} }
/// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。 /// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。
///
/// 纵深防御:即便第一道白名单 `is_valid_backup_filename` 被绕过,这里也要
/// 保证结果不逃出 BACKUP_DIR。直接对 filename 做 components 检查——
/// 含 `..`ParentDir、绝对路径前缀RootDir/Prefix如 `/etc` 或 `C:\`
/// 的 filename 一律降级为 BACKUP_DIR 本身。
///
/// 注意:不能用 `[BACKUP_DIR, filename].collect::<PathBuf>()` 后再检——
/// 当 filename 是绝对路径时PathBuf 语义会丢弃 BACKUP_DIR 前缀(如
/// `["backups", "/etc/passwd"]` → `/etc/passwd`),导致 components 检查
/// 在错位的路径上运行而漏判。必须先检 filename 本身。
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn backup_path(filename: &str) -> PathBuf { fn backup_path(filename: &str) -> PathBuf {
let raw: PathBuf = [BACKUP_DIR, filename].iter().collect(); // 直接检查 filename 的 components只允许 Normal 段。
// 确保规范化后首两段仍是 BACKUP_DIR/filename防任何路径穿越残留 let filename_is_safe = std::path::Path::new(filename)
let mut it = raw.components(); .components()
let _ = it.next(); // BACKUP_DIR .all(|c| matches!(c, Component::Normal(_)));
if it.all(|c| !matches!(c, Component::ParentDir | Component::RootDir)) { if filename_is_safe {
raw let mut p = PathBuf::from(BACKUP_DIR);
p.push(filename);
p
} else { } else {
// 命中 ParentDir/RootDir/Prefix/CurDir → 降级为 BACKUP_DIR
PathBuf::from(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: <value>` 行的值(如 "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 鉴权 + 路径白名单)。 /// Axum 处理器下载备份文件admin 鉴权 + 路径白名单)。
/// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。 /// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -569,8 +606,7 @@ pub async fn download_backup(
} }
// 路径白名单 // 路径白名单
let re = regex::Regex::new(FILENAME_RE).unwrap(); if !is_valid_backup_filename(&filename) {
if !re.is_match(&filename) {
return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string())); return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
} }
let path = backup_path(&filename); let path = backup_path(&filename);
@ -594,3 +630,154 @@ pub async fn download_backup(
axum::body::Body::from(bytes), 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");
}
}