fix(image): add canonicalize prefix check to is_path_safe for defense in depth

子串检查之外,对已存在文件做 canonicalize 前缀校验,确认解析后路径仍在
uploads 目录内,抵御符号链接等绕过(L4)。文件或 uploads 目录不存在时
只靠第一层校验(交由后续读取报 404)。函数改为 async,调用点与测试同步更新。
This commit is contained in:
xfy 2026-06-18 13:39:46 +08:00
parent 7a6e9350fe
commit 3723cd03f9

View File

@ -373,16 +373,24 @@ fn process_image_blocking(
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
fn is_path_safe(path: &str) -> bool { /// 校验请求路径不会逃出 uploads 目录。
// Reject paths with parent directory references or null bytes ///
if path.contains("..") || path.contains('\0') { /// 两层校验:① 子串级拒绝 `..`/`\0`/绝对路径前缀;② 对已存在文件用 canonicalize
/// 确认解析后真实路径仍在 uploads 目录内(纵深防御,抵御符号链接等绕过)。
/// 文件不存在或 uploads 目录不存在时只做第一层校验(由后续 read 报 404
async fn is_path_safe(path: &str) -> bool {
if path.contains("..") || path.contains('\0') || path.starts_with('/') {
return false; return false;
} }
// Reject absolute paths let candidate = std::path::Path::new("uploads").join(path);
if path.starts_with('/') { let uploads_root = match tokio::fs::canonicalize("uploads").await {
return false; Ok(p) => p,
Err(_) => return true, // uploads 目录不存在(测试环境),只靠第一层校验。
};
match tokio::fs::canonicalize(&candidate).await {
Ok(resolved) => resolved.starts_with(&uploads_root),
Err(_) => true, // 文件不存在,交由后续读取报 404。
} }
true
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -452,7 +460,7 @@ pub async fn serve_image(
return status.into_response(); return status.into_response();
} }
if !is_path_safe(&path) { if !is_path_safe(&path).await {
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
} }
@ -654,26 +662,26 @@ mod tests {
assert!(params.validate().is_err()); assert!(params.validate().is_err());
} }
#[test] #[tokio::test]
fn is_path_safe_normal() { async fn is_path_safe_normal() {
assert!(is_path_safe("images/photo.jpg")); assert!(is_path_safe("images/photo.jpg").await);
assert!(is_path_safe("2024/01/photo.png")); assert!(is_path_safe("2024/01/photo.png").await);
} }
#[test] #[tokio::test]
fn is_path_safe_rejects_parent_dir() { async fn is_path_safe_rejects_parent_dir() {
assert!(!is_path_safe("../etc/passwd")); assert!(!is_path_safe("../etc/passwd").await);
assert!(!is_path_safe("foo/../../bar")); assert!(!is_path_safe("foo/../../bar").await);
} }
#[test] #[tokio::test]
fn is_path_safe_rejects_null_bytes() { async fn is_path_safe_rejects_null_bytes() {
assert!(!is_path_safe("foo\0bar")); assert!(!is_path_safe("foo\0bar").await);
} }
#[test] #[tokio::test]
fn is_path_safe_rejects_absolute_path() { async fn is_path_safe_rejects_absolute_path() {
assert!(!is_path_safe("/etc/passwd")); assert!(!is_path_safe("/etc/passwd").await);
} }
#[test] #[test]