Enterprise-grade SFTP reliability improvements
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled

- Remove all unwrap() calls from SftpAttrs::serialize() and from_metadata()
- Add extension advertisement in SSH_FXP_VERSION (10 extensions declared)
- Map std::io::ErrorKind to proper SSH_FX_* status codes (NotFound→FX_NO_SUCH_FILE etc.)
- Add restrict_absolute flag for chroot-like path confinement mode
- Add MAX_HANDLES limit (4096) to prevent handle exhaustion
- Add MAX_XFER_SIZE (1MB) and MAX_HASH_SIZE (256MB) OOM protection
- Fix test compilation errors (SftpHandler::new signature)
- Add build_status_from_io_error() helper for consistent error mapping
This commit is contained in:
Warren
2026-06-18 06:42:33 +08:00
parent 5344a7c16e
commit 1d81db3af5

View File

@@ -2,7 +2,7 @@
// 参考OpenSSH sftp-server.c和draft-ietf-secsh-filexfer-02.txt // 参考OpenSSH sftp-server.c和draft-ietf-secsh-filexfer-02.txt
use crate::ssh_server::packet::{SshPacket, PacketType}; use crate::ssh_server::packet::{SshPacket, PacketType};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow, Context};
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use log::{info, warn, debug}; use log::{info, warn, debug};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -155,83 +155,89 @@ impl SftpAttrs {
pub fn from_metadata(metadata: &fs::Metadata) -> Self { pub fn from_metadata(metadata: &fs::Metadata) -> Self {
let mut attrs = Self::new(); let mut attrs = Self::new();
// ⭐⭐⭐⭐⭐ Phase 2.2: 添加 uid/gid 字段(修复 "? 0 0" 权限显示问题)
attrs.flags = SftpAttrFlags::SSH_FILEXFER_ATTR_SIZE attrs.flags = SftpAttrFlags::SSH_FILEXFER_ATTR_SIZE
| SftpAttrFlags::SSH_FILEXFER_ATTR_UIDGID // ⭐⭐⭐⭐⭐ 添加 UIDGID flag | SftpAttrFlags::SSH_FILEXFER_ATTR_UIDGID
| SftpAttrFlags::SSH_FILEXFER_ATTR_PERMISSIONS | SftpAttrFlags::SSH_FILEXFER_ATTR_PERMISSIONS
| SftpAttrFlags::SSH_FILEXFER_ATTR_ACMODTIME; | SftpAttrFlags::SSH_FILEXFER_ATTR_ACMODTIME;
attrs.size = Some(metadata.len()); attrs.size = Some(metadata.len());
attrs.permissions = Some(metadata.permissions().mode()); attrs.permissions = Some(metadata.permissions().mode());
// ⭐⭐⭐⭐⭐ Phase 2.2: 获取 uid/gid参考OpenSSH sftp-server.c: stat_to_attrib
attrs.uid = Some(metadata.uid()); attrs.uid = Some(metadata.uid());
attrs.gid = Some(metadata.gid()); attrs.gid = Some(metadata.gid());
if let Ok(atime) = metadata.accessed() { if let Ok(atime) = metadata.accessed() {
attrs.atime = Some(atime.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as u32); attrs.atime = atime.duration_since(std::time::UNIX_EPOCH)
.ok().map(|d| d.as_secs() as u32);
} }
if let Ok(mtime) = metadata.modified() { if let Ok(mtime) = metadata.modified() {
attrs.mtime = Some(mtime.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as u32); attrs.mtime = mtime.duration_since(std::time::UNIX_EPOCH)
.ok().map(|d| d.as_secs() as u32);
} }
attrs attrs
} }
pub fn serialize(&self) -> Vec<u8> { pub fn serialize(&self) -> Result<Vec<u8>> {
// ⭐⭐⭐⭐⭐ Phase 1.3: 添加 SSH_FXP_ATTRS 详细日志 debug!("Serializing SftpAttrs: flags=0x{:08x}, size={:?}, uid={:?}, gid={:?}, permissions=0x{:08x}, atime={:?}, mtime={:?}",
debug!("Serializing SftpAttrs: flags=0x{:08x}, size={}, uid={}, gid={}, permissions=0x{:08x}, atime={}, mtime={}", self.flags, self.size, self.uid, self.gid,
self.flags,
self.size.unwrap_or(0),
self.uid.unwrap_or(0),
self.gid.unwrap_or(0),
self.permissions.unwrap_or(0), self.permissions.unwrap_or(0),
self.atime.unwrap_or(0), self.atime, self.mtime,
self.mtime.unwrap_or(0)
); );
let mut buffer = Vec::new(); let mut buffer = Vec::new();
buffer.write_u32::<BigEndian>(self.flags).unwrap(); buffer.write_u32::<BigEndian>(self.flags)
.with_context(|| "serialize attrs flags")?;
if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_SIZE != 0 { if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_SIZE != 0 {
if let Some(size) = self.size { if let Some(size) = self.size {
buffer.write_u64::<BigEndian>(size).unwrap(); buffer.write_u64::<BigEndian>(size)
.with_context(|| "serialize attrs size")?;
} }
} }
if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_UIDGID != 0 { if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_UIDGID != 0 {
if let (Some(uid), Some(gid)) = (self.uid, self.gid) { if let (Some(uid), Some(gid)) = (self.uid, self.gid) {
buffer.write_u32::<BigEndian>(uid).unwrap(); buffer.write_u32::<BigEndian>(uid)
buffer.write_u32::<BigEndian>(gid).unwrap(); .with_context(|| "serialize attrs uid")?;
buffer.write_u32::<BigEndian>(gid)
.with_context(|| "serialize attrs gid")?;
} }
} }
if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_PERMISSIONS != 0 { if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_PERMISSIONS != 0 {
if let Some(permissions) = self.permissions { if let Some(permissions) = self.permissions {
buffer.write_u32::<BigEndian>(permissions).unwrap(); buffer.write_u32::<BigEndian>(permissions)
.with_context(|| "serialize attrs perms")?;
} }
} }
if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_ACMODTIME != 0 { if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_ACMODTIME != 0 {
if let (Some(atime), Some(mtime)) = (self.atime, self.mtime) { if let (Some(atime), Some(mtime)) = (self.atime, self.mtime) {
buffer.write_u32::<BigEndian>(atime).unwrap(); buffer.write_u32::<BigEndian>(atime)
buffer.write_u32::<BigEndian>(mtime).unwrap(); .with_context(|| "serialize attrs atime")?;
buffer.write_u32::<BigEndian>(mtime)
.with_context(|| "serialize attrs mtime")?;
} }
} }
if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_EXTENDED != 0 { if self.flags & SftpAttrFlags::SSH_FILEXFER_ATTR_EXTENDED != 0 {
buffer.write_u32::<BigEndian>(self.extended.len() as u32).unwrap(); buffer.write_u32::<BigEndian>(self.extended.len() as u32)
.with_context(|| "serialize attrs ext count")?;
for (name, value) in &self.extended { for (name, value) in &self.extended {
buffer.write_u32::<BigEndian>(name.len() as u32).unwrap(); buffer.write_u32::<BigEndian>(name.len() as u32)
buffer.write_all(name.as_bytes()).unwrap(); .with_context(|| "serialize attrs ext name len")?;
buffer.write_u32::<BigEndian>(value.len() as u32).unwrap(); buffer.write_all(name.as_bytes())
buffer.write_all(value.as_bytes()).unwrap(); .with_context(|| "serialize attrs ext name")?;
buffer.write_u32::<BigEndian>(value.len() as u32)
.with_context(|| "serialize attrs ext value len")?;
buffer.write_all(value.as_bytes())
.with_context(|| "serialize attrs ext value")?;
} }
} }
buffer Ok(buffer)
} }
} }
@@ -258,9 +264,18 @@ pub struct SftpHandler {
handles: std::collections::HashMap<u32, SftpHandle>, handles: std::collections::HashMap<u32, SftpHandle>,
// ⭐⭐⭐⭐⭐ Phase 4: 添加 client maxpack 限制参考OpenSSH sftp-server.c // ⭐⭐⭐⭐⭐ Phase 4: 添加 client maxpack 限制参考OpenSSH sftp-server.c
maxpacket: u32, // 来自 SSH_MSG_CHANNEL_OPEN_CONFIRMATION 的 maximum_packet_size maxpacket: u32, // 来自 SSH_MSG_CHANNEL_OPEN_CONFIRMATION 的 maximum_packet_size
/// 限制绝对路径也在 root_dir 之下chroot 模式)
restrict_absolute: bool,
} }
impl SftpHandler { impl SftpHandler {
/// 最大并发handle数防止资源耗尽
const MAX_HANDLES: usize = 4096;
/// 单次读写最大字节数1MB防止OOM
const MAX_XFER_SIZE: u32 = 1_048_576;
/// 单次hash最大字节数256MB平衡安全与性能
const MAX_HASH_SIZE: u64 = 268_435_456;
// ⭐⭐⭐⭐⭐ Phase 4: 修改 new() 方法,接受 maxpack 参数 // ⭐⭐⭐⭐⭐ Phase 4: 修改 new() 方法,接受 maxpack 参数
pub fn new(root_dir: PathBuf, maxpacket: u32) -> Self { pub fn new(root_dir: PathBuf, maxpacket: u32) -> Self {
let canonical_root = root_dir.canonicalize().unwrap_or(root_dir); let canonical_root = root_dir.canonicalize().unwrap_or(root_dir);
@@ -268,10 +283,16 @@ impl SftpHandler {
root_dir: canonical_root, root_dir: canonical_root,
next_handle_id: 0, next_handle_id: 0,
handles: std::collections::HashMap::new(), handles: std::collections::HashMap::new(),
maxpacket, // ⭐⭐⭐⭐⭐ Phase 4: client maxpack 限制 maxpacket,
restrict_absolute: false, // 默认允许绝对路径
} }
} }
/// 设置 restrict_absolute 模式chroot-like
pub fn set_restrict_absolute(&mut self, restrict: bool) {
self.restrict_absolute = restrict;
}
/// 处理SFTP请求参考OpenSSH sftp-server.c: process()) /// 处理SFTP请求参考OpenSSH sftp-server.c: process())
pub fn handle_request(&mut self, data: &[u8]) -> Result<Vec<u8>> { pub fn handle_request(&mut self, data: &[u8]) -> Result<Vec<u8>> {
if data.is_empty() { if data.is_empty() {
@@ -320,6 +341,21 @@ impl SftpHandler {
let version = cursor.read_u32::<BigEndian>()?; let version = cursor.read_u32::<BigEndian>()?;
info!("Client SFTP version: {}", version); info!("Client SFTP version: {}", version);
// Read any extension data client sent (SSH_FXP_INIT may contain extensions)
let pos = cursor.position() as usize;
let inner = cursor.get_ref();
if inner.len() > pos && (inner.len() - pos) >= 4 {
let ext_count = match cursor.read_u32::<BigEndian>() {
Ok(n) => n,
Err(_) => 0,
};
for i in 0..ext_count {
let ext_name = read_sftp_string(&mut cursor).unwrap_or_default();
let ext_data = read_sftp_string(&mut cursor).unwrap_or_default();
debug!("Client extension[{}]: {} = {}", i, ext_name, ext_data);
}
}
let response = self.build_version_response(3)?; let response = self.build_version_response(3)?;
Ok(response) Ok(response)
} }
@@ -364,6 +400,10 @@ impl SftpHandler {
match file { match file {
Some(file) => { Some(file) => {
if self.handles.len() >= Self::MAX_HANDLES {
warn!("SSH_FXP_OPEN: handle limit reached ({})", Self::MAX_HANDLES);
return self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, "Handle limit reached");
}
let handle_id = self.next_handle_id; let handle_id = self.next_handle_id;
self.next_handle_id += 1; self.next_handle_id += 1;
@@ -424,10 +464,8 @@ impl SftpHandler {
if let Some(ref mut file) = handle.file { if let Some(ref mut file) = handle.file {
file.seek(SeekFrom::Start(offset))?; file.seek(SeekFrom::Start(offset))?;
// ⭐⭐⭐⭐⭐ Phase 4: 限制数据大小,不超过 maxpacket - 1024参考OpenSSH sftp-server.c // ⭐⭐⭐⭐⭐ Phase 4: 限制数据大小,不超过 maxpacket - 1024 和 MAX_XFER_SIZE
// OpenSSH sftp-server.c: process_read() 中限制数据大小为 maxpacket - 1024 bytes let max_data_size = std::cmp::min(self.maxpacket.saturating_sub(1024), Self::MAX_XFER_SIZE);
// SSH packet overhead: ~1024 bytes (SSH header + SSH_FXP_DATA header)
let max_data_size = self.maxpacket - 1024;
let actual_length = std::cmp::min(length, max_data_size); let actual_length = std::cmp::min(length, max_data_size);
info!("SSH_FXP_READ limited: requested={}, actual={}", length, actual_length); info!("SSH_FXP_READ limited: requested={}, actual={}", length, actual_length);
@@ -442,7 +480,7 @@ impl SftpHandler {
self.build_data_response(id, &buffer) self.build_data_response(id, &buffer)
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Read error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} else { } else {
@@ -484,7 +522,7 @@ impl SftpHandler {
self.build_status_response(id, SftpStatus::SSH_FX_OK, "Write successful") self.build_status_response(id, SftpStatus::SSH_FX_OK, "Write successful")
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Write error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} else { } else {
@@ -540,7 +578,7 @@ impl SftpHandler {
self.build_attrs_response(id, &attrs) self.build_attrs_response(id, &attrs)
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Fstat error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} else { } else {
@@ -564,6 +602,10 @@ impl SftpHandler {
match fs::read_dir(&full_path) { match fs::read_dir(&full_path) {
Ok(entries) => { Ok(entries) => {
if self.handles.len() >= Self::MAX_HANDLES {
warn!("SSH_FXP_OPENDIR: handle limit reached ({})", Self::MAX_HANDLES);
return self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, "Handle limit reached");
}
let handle_id = self.next_handle_id; let handle_id = self.next_handle_id;
self.next_handle_id += 1; self.next_handle_id += 1;
@@ -582,7 +624,7 @@ impl SftpHandler {
self.build_handle_response(id, &handle_id.to_be_bytes()) self.build_handle_response(id, &handle_id.to_be_bytes())
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Opendir error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -648,7 +690,7 @@ impl SftpHandler {
self.build_status_response(id, SftpStatus::SSH_FX_OK, "File removed") self.build_status_response(id, SftpStatus::SSH_FX_OK, "File removed")
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Remove error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -673,7 +715,7 @@ impl SftpHandler {
self.build_status_response(id, SftpStatus::SSH_FX_OK, "Directory created") self.build_status_response(id, SftpStatus::SSH_FX_OK, "Directory created")
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Mkdir error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -697,7 +739,7 @@ impl SftpHandler {
self.build_status_response(id, SftpStatus::SSH_FX_OK, "Directory removed") self.build_status_response(id, SftpStatus::SSH_FX_OK, "Directory removed")
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Rmdir error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -770,7 +812,7 @@ impl SftpHandler {
self.build_status_response(id, SftpStatus::SSH_FX_OK, "Rename successful") self.build_status_response(id, SftpStatus::SSH_FX_OK, "Rename successful")
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Rename error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -864,7 +906,7 @@ impl SftpHandler {
self.build_name_response(id, vec![(target, SftpAttrs::default())]) self.build_name_response(id, vec![(target, SftpAttrs::default())])
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Readlink error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -891,7 +933,7 @@ impl SftpHandler {
self.build_status_response(id, SftpStatus::SSH_FX_OK, "Symlink created") self.build_status_response(id, SftpStatus::SSH_FX_OK, "Symlink created")
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Symlink error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
@@ -994,7 +1036,7 @@ impl SftpHandler {
self.wrap_sftp_packet(&response) self.wrap_sftp_packet(&response)
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("statvfs error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -1052,7 +1094,7 @@ impl SftpHandler {
self.build_status_response(id, SftpStatus::SSH_FX_OK, "Hardlink created") self.build_status_response(id, SftpStatus::SSH_FX_OK, "Hardlink created")
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Hardlink error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
@@ -1075,7 +1117,7 @@ impl SftpHandler {
self.build_status_response(id, SftpStatus::SSH_FX_OK, "Posix rename successful") self.build_status_response(id, SftpStatus::SSH_FX_OK, "Posix rename successful")
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Posix rename error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -1088,13 +1130,18 @@ impl SftpHandler {
info!("md5-hash: path={}, offset={}, length={}", path, offset, length); info!("md5-hash: path={}, offset={}, length={}", path, offset, length);
let actual_length = std::cmp::min(length, Self::MAX_HASH_SIZE);
if actual_length < length {
warn!("md5-hash: length reduced from {} to {} (MAX_HASH_SIZE)", length, actual_length);
}
let full_path = self.resolve_path(&path)?; let full_path = self.resolve_path(&path)?;
match File::open(&full_path) { match File::open(&full_path) {
Ok(mut file) => { Ok(mut file) => {
file.seek(SeekFrom::Start(offset))?; file.seek(SeekFrom::Start(offset))?;
let mut buffer = vec![0u8; length as usize]; let mut buffer = vec![0u8; actual_length as usize];
file.read_exact(&mut buffer)?; file.read_exact(&mut buffer)?;
// 计算MD5哈希 // 计算MD5哈希
@@ -1117,7 +1164,7 @@ impl SftpHandler {
self.wrap_sftp_packet(&response) self.wrap_sftp_packet(&response)
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("MD5 hash error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -1130,13 +1177,18 @@ impl SftpHandler {
info!("sha256-hash: path={}, offset={}, length={}", path, offset, length); info!("sha256-hash: path={}, offset={}, length={}", path, offset, length);
let actual_length = std::cmp::min(length, Self::MAX_HASH_SIZE);
if actual_length < length {
warn!("sha256-hash: length reduced from {} to {} (MAX_HASH_SIZE)", length, actual_length);
}
let full_path = self.resolve_path(&path)?; let full_path = self.resolve_path(&path)?;
match File::open(&full_path) { match File::open(&full_path) {
Ok(mut file) => { Ok(mut file) => {
file.seek(SeekFrom::Start(offset))?; file.seek(SeekFrom::Start(offset))?;
let mut buffer = vec![0u8; length as usize]; let mut buffer = vec![0u8; actual_length as usize];
file.read_exact(&mut buffer)?; file.read_exact(&mut buffer)?;
// 计算SHA256哈希使用sha2 crate // 计算SHA256哈希使用sha2 crate
@@ -1162,7 +1214,7 @@ impl SftpHandler {
self.wrap_sftp_packet(&response) self.wrap_sftp_packet(&response)
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("SHA256 hash error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -1175,13 +1227,18 @@ impl SftpHandler {
info!("sha384-hash: path={}, offset={}, length={}", path, offset, length); info!("sha384-hash: path={}, offset={}, length={}", path, offset, length);
let actual_length = std::cmp::min(length, Self::MAX_HASH_SIZE);
if actual_length < length {
warn!("sha384-hash: length reduced from {} to {} (MAX_HASH_SIZE)", length, actual_length);
}
let full_path = self.resolve_path(&path)?; let full_path = self.resolve_path(&path)?;
match File::open(&full_path) { match File::open(&full_path) {
Ok(mut file) => { Ok(mut file) => {
file.seek(SeekFrom::Start(offset))?; file.seek(SeekFrom::Start(offset))?;
let mut buffer = vec![0u8; length as usize]; let mut buffer = vec![0u8; actual_length as usize];
file.read_exact(&mut buffer)?; file.read_exact(&mut buffer)?;
// 计算SHA384哈希 // 计算SHA384哈希
@@ -1205,7 +1262,7 @@ impl SftpHandler {
self.wrap_sftp_packet(&response) self.wrap_sftp_packet(&response)
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("SHA384 hash error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -1218,13 +1275,18 @@ impl SftpHandler {
info!("sha512-hash: path={}, offset={}, length={}", path, offset, length); info!("sha512-hash: path={}, offset={}, length={}", path, offset, length);
let actual_length = std::cmp::min(length, Self::MAX_HASH_SIZE);
if actual_length < length {
warn!("sha512-hash: length reduced from {} to {} (MAX_HASH_SIZE)", length, actual_length);
}
let full_path = self.resolve_path(&path)?; let full_path = self.resolve_path(&path)?;
match File::open(&full_path) { match File::open(&full_path) {
Ok(mut file) => { Ok(mut file) => {
file.seek(SeekFrom::Start(offset))?; file.seek(SeekFrom::Start(offset))?;
let mut buffer = vec![0u8; length as usize]; let mut buffer = vec![0u8; actual_length as usize];
file.read_exact(&mut buffer)?; file.read_exact(&mut buffer)?;
// 计算SHA512哈希 // 计算SHA512哈希
@@ -1248,7 +1310,7 @@ impl SftpHandler {
self.wrap_sftp_packet(&response) self.wrap_sftp_packet(&response)
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("SHA512 hash error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
@@ -1298,6 +1360,11 @@ impl SftpHandler {
u32::from_be_bytes([write_handle_bytes[0], write_handle_bytes[1], write_handle_bytes[2], write_handle_bytes[3]]), u32::from_be_bytes([write_handle_bytes[0], write_handle_bytes[1], write_handle_bytes[2], write_handle_bytes[3]]),
write_offset); write_offset);
let actual_length = std::cmp::min(read_length, Self::MAX_XFER_SIZE as u64);
if actual_length < read_length {
warn!("copy-data: length reduced from {} to {} (MAX_XFER_SIZE)", read_length, actual_length);
}
let read_handle_id = u32::from_be_bytes([read_handle_bytes[0], read_handle_bytes[1], read_handle_bytes[2], read_handle_bytes[3]]); let read_handle_id = u32::from_be_bytes([read_handle_bytes[0], read_handle_bytes[1], read_handle_bytes[2], read_handle_bytes[3]]);
let write_handle_id = u32::from_be_bytes([write_handle_bytes[0], write_handle_bytes[1], write_handle_bytes[2], write_handle_bytes[3]]); let write_handle_id = u32::from_be_bytes([write_handle_bytes[0], write_handle_bytes[1], write_handle_bytes[2], write_handle_bytes[3]]);
@@ -1319,7 +1386,7 @@ impl SftpHandler {
match File::open(&read_path) { match File::open(&read_path) {
Ok(mut read_file) => { Ok(mut read_file) => {
read_file.seek(SeekFrom::Start(read_offset))?; read_file.seek(SeekFrom::Start(read_offset))?;
let mut buffer = vec![0u8; read_length as usize]; let mut buffer = vec![0u8; actual_length as usize];
read_file.read_exact(&mut buffer)?; read_file.read_exact(&mut buffer)?;
// 写入到write_path // 写入到write_path
@@ -1334,72 +1401,112 @@ impl SftpHandler {
response.write_u32::<BigEndian>(id)?; response.write_u32::<BigEndian>(id)?;
// 返回复制的字节数 // 返回复制的字节数
response.write_u64::<BigEndian>(read_length)?; response.write_u64::<BigEndian>(actual_length)?;
self.wrap_sftp_packet(&response) self.wrap_sftp_packet(&response)
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Write file error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
Err(e) => { Err(e) => {
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Read file error: {}", e)) self.build_status_from_io_error(id, &e)
} }
} }
} }
/// 解析路径安全性检查参考OpenSSH sftp-server.c: path_resolve()) /// 解析路径安全性检查参考OpenSSH sftp-server.c: path_resolve()
///
/// 安全策略:
/// - 相对路径:始终限制在 root_dir 之下
/// - 绝对路径restrict_absolute=false允许用户依赖文件系统权限
/// - 绝对路径restrict_absolute=true限制在 root_dir 之下chroot 模式)
fn resolve_path(&self, path: &str) -> Result<PathBuf> { fn resolve_path(&self, path: &str) -> Result<PathBuf> {
info!("resolve_path: input={}, root_dir={:?}", path, self.root_dir); info!("resolve_path: input={}, root_dir={:?}", path, self.root_dir);
let full_path = if path.is_empty() || path == "." { let full_path = if path.is_empty() || path == "." {
self.root_dir.clone() self.root_dir.clone()
} else if path.starts_with('/') { } else if path.starts_with('/') {
// Absolute path: allow access to any path (like /tmp)
PathBuf::from(path) PathBuf::from(path)
} else { } else {
// Relative path: must be under root_dir
self.root_dir.join(path) self.root_dir.join(path)
}; };
info!("resolve_path: full_path={:?}", full_path); info!("resolve_path: full_path={:?}", full_path);
// Security: Only enforce root_dir check for relative paths let is_absolute = path.starts_with('/');
// Absolute paths are allowed (user can access any path they have filesystem permissions for)
if path.starts_with('/') { // 检查路径遍历:对相对路径始终执行,对绝对路径仅在 restrict_absolute 模式下执行
// Absolute path: no root_dir check, just return canonicalized path if exists let need_check = !is_absolute || self.restrict_absolute;
if need_check {
if full_path.exists() {
let canonical = full_path.canonicalize()
.map_err(|e| anyhow!("Path canonicalize error: {}", e))?;
if !canonical.starts_with(&self.root_dir) {
return Err(anyhow!("Path traversal: {:?} not under {:?}", canonical, self.root_dir));
}
Ok(canonical)
} else {
// Pre-resolve parent directory for non-existent paths
if let Some(parent) = full_path.parent() {
if parent.exists() {
let canonical_parent = parent.canonicalize()
.map_err(|e| anyhow!("Parent canonicalize error: {}", e))?;
let resolved = canonical_parent.join(
full_path.file_name().unwrap_or_default()
);
if !resolved.starts_with(&self.root_dir) {
return Err(anyhow!("Path traversal: {:?} not under {:?}", resolved, self.root_dir));
}
return Ok(resolved);
}
}
if !full_path.starts_with(&self.root_dir) {
return Err(anyhow!("Path traversal: {:?} not under {:?}", full_path, self.root_dir));
}
Ok(full_path)
}
} else {
// Absolute path, unrestricted: canonicalize if exists, else return as-is
if full_path.exists() { if full_path.exists() {
Ok(full_path.canonicalize()?) Ok(full_path.canonicalize()?)
} else { } else {
Ok(full_path) Ok(full_path)
} }
} else {
// Relative path: enforce strict root_dir confinement
if full_path.exists() {
let canonical_path = full_path.canonicalize()?;
if !canonical_path.starts_with(&self.root_dir) {
return Err(anyhow!("Path traversal attempt detected: {:?} not under {:?}", canonical_path, self.root_dir));
}
Ok(canonical_path)
} else {
if !full_path.starts_with(&self.root_dir) {
return Err(anyhow!("Path traversal attempt detected: {:?} not under {:?}", full_path, self.root_dir));
}
Ok(full_path)
}
} }
} }
/// 构建SSH_FXP_VERSION响应参考OpenSSH sftp-server.c /// 构建SSH_FXP_VERSION响应,包含扩展声明参考OpenSSH sftp-server.c
fn build_version_response(&self, version: u32) -> Result<Vec<u8>> { fn build_version_response(&self, version: u32) -> Result<Vec<u8>> {
let mut buffer = Vec::new(); let mut buffer = Vec::new();
// SSH_FXP_VERSION packet
buffer.write_u8(SftpPacketType::SSH_FXP_VERSION as u8)?; buffer.write_u8(SftpPacketType::SSH_FXP_VERSION as u8)?;
buffer.write_u32::<BigEndian>(version)?; buffer.write_u32::<BigEndian>(version)?;
// Phase 7: SFTP packet需要SSH string格式uint32(length) + packet_type + payload // 扩展声明OpenSSH sftp-server.c: process_init() 中声明支持的扩展
let extensions: &[(&str, &str)] = &[
("posix-rename@openssh.com", "1"),
("hardlink@openssh.com", "1"),
("copy-data@openssh.com", "1"),
("check-file@openssh.com", "1"),
("statvfs@openssh.com", "2"),
("fstatvfs@openssh.com", "2"),
("md5-hash@openssh.com", "1"),
("sha256-hash@openssh.com", "1"),
("sha384-hash@openssh.com", "1"),
("sha512-hash@openssh.com", "1"),
];
buffer.write_u32::<BigEndian>(extensions.len() as u32)?;
for (name, data) in extensions {
buffer.write_u32::<BigEndian>(name.len() as u32)?;
buffer.write_all(name.as_bytes())?;
buffer.write_u32::<BigEndian>(data.len() as u32)?;
buffer.write_all(data.as_bytes())?;
}
self.wrap_sftp_packet(&buffer) self.wrap_sftp_packet(&buffer)
} }
@@ -1468,7 +1575,7 @@ impl SftpHandler {
buffer.write_u32::<BigEndian>(long_name.len() as u32)?; buffer.write_u32::<BigEndian>(long_name.len() as u32)?;
buffer.write_all(long_name.as_bytes())?; buffer.write_all(long_name.as_bytes())?;
buffer.write_all(&attrs.serialize())?; buffer.write_all(&attrs.serialize()?)?;
} }
self.wrap_sftp_packet(&buffer) self.wrap_sftp_packet(&buffer)
@@ -1480,10 +1587,30 @@ impl SftpHandler {
buffer.write_u8(SftpPacketType::SSH_FXP_ATTRS as u8)?; buffer.write_u8(SftpPacketType::SSH_FXP_ATTRS as u8)?;
buffer.write_u32::<BigEndian>(id)?; buffer.write_u32::<BigEndian>(id)?;
buffer.write_all(&attrs.serialize())?; buffer.write_all(&attrs.serialize()?)?;
self.wrap_sftp_packet(&buffer) self.wrap_sftp_packet(&buffer)
} }
/// 将 std::io::Error 映射为对应的 SSH_FX_* 状态码
fn map_io_error_kind(err: &std::io::Error) -> SftpStatus {
match err.kind() {
std::io::ErrorKind::NotFound => SftpStatus::SSH_FX_NO_SUCH_FILE,
std::io::ErrorKind::PermissionDenied => SftpStatus::SSH_FX_PERMISSION_DENIED,
std::io::ErrorKind::AlreadyExists => SftpStatus::SSH_FX_FAILURE,
std::io::ErrorKind::InvalidInput => SftpStatus::SSH_FX_BAD_MESSAGE,
std::io::ErrorKind::WriteZero => SftpStatus::SSH_FX_FAILURE,
std::io::ErrorKind::UnexpectedEof => SftpStatus::SSH_FX_EOF,
_ => SftpStatus::SSH_FX_FAILURE,
}
}
/// 根据 Error 构建状态响应(自动映射错误类型)
fn build_status_from_io_error(&self, id: u32, err: &std::io::Error) -> Result<Vec<u8>> {
let status = Self::map_io_error_kind(err);
let msg = format!("{}", err);
self.build_status_response(id, status, &msg)
}
} }
/// 读取SFTP字符串参考draft-ietf-secsh-filexfer-02.txt /// 读取SFTP字符串参考draft-ietf-secsh-filexfer-02.txt
@@ -1553,7 +1680,7 @@ mod tests {
#[test] #[test]
fn test_sftp_handler_creation() { fn test_sftp_handler_creation() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let handler = SftpHandler::new(temp_dir.path().to_path_buf()); let handler = SftpHandler::new(temp_dir.path().to_path_buf(), 32768);
assert_eq!(handler.next_handle_id, 0); assert_eq!(handler.next_handle_id, 0);
} }
@@ -1573,7 +1700,7 @@ mod tests {
#[test] #[test]
fn test_sftp_handle_init() { fn test_sftp_handle_init() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let mut handler = SftpHandler::new(temp_dir.path().to_path_buf()); let mut handler = SftpHandler::new(temp_dir.path().to_path_buf(), 32768);
let init_packet = vec![1, 0, 0, 0, 3]; let init_packet = vec![1, 0, 0, 0, 3];
let response = handler.handle_request(&init_packet).unwrap(); let response = handler.handle_request(&init_packet).unwrap();