Enterprise-grade SFTP reliability improvements
- 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:
@@ -2,7 +2,7 @@
|
||||
// 参考OpenSSH sftp-server.c和draft-ietf-secsh-filexfer-02.txt
|
||||
|
||||
use crate::ssh_server::packet::{SshPacket, PacketType};
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{Result, anyhow, Context};
|
||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||
use log::{info, warn, debug};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -155,83 +155,89 @@ impl SftpAttrs {
|
||||
pub fn from_metadata(metadata: &fs::Metadata) -> Self {
|
||||
let mut attrs = Self::new();
|
||||
|
||||
// ⭐⭐⭐⭐⭐ Phase 2.2: 添加 uid/gid 字段(修复 "? 0 0" 权限显示问题)
|
||||
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_ACMODTIME;
|
||||
|
||||
attrs.size = Some(metadata.len());
|
||||
attrs.permissions = Some(metadata.permissions().mode());
|
||||
|
||||
// ⭐⭐⭐⭐⭐ Phase 2.2: 获取 uid/gid(参考OpenSSH sftp-server.c: stat_to_attrib)
|
||||
attrs.uid = Some(metadata.uid());
|
||||
attrs.gid = Some(metadata.gid());
|
||||
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
// ⭐⭐⭐⭐⭐ Phase 1.3: 添加 SSH_FXP_ATTRS 详细日志
|
||||
debug!("Serializing SftpAttrs: flags=0x{:08x}, size={}, uid={}, gid={}, permissions=0x{:08x}, atime={}, mtime={}",
|
||||
self.flags,
|
||||
self.size.unwrap_or(0),
|
||||
self.uid.unwrap_or(0),
|
||||
self.gid.unwrap_or(0),
|
||||
pub fn serialize(&self) -> Result<Vec<u8>> {
|
||||
debug!("Serializing SftpAttrs: flags=0x{:08x}, size={:?}, uid={:?}, gid={:?}, permissions=0x{:08x}, atime={:?}, mtime={:?}",
|
||||
self.flags, self.size, self.uid, self.gid,
|
||||
self.permissions.unwrap_or(0),
|
||||
self.atime.unwrap_or(0),
|
||||
self.mtime.unwrap_or(0)
|
||||
self.atime, self.mtime,
|
||||
);
|
||||
|
||||
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 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 let (Some(uid), Some(gid)) = (self.uid, self.gid) {
|
||||
buffer.write_u32::<BigEndian>(uid).unwrap();
|
||||
buffer.write_u32::<BigEndian>(gid).unwrap();
|
||||
buffer.write_u32::<BigEndian>(uid)
|
||||
.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 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 let (Some(atime), Some(mtime)) = (self.atime, self.mtime) {
|
||||
buffer.write_u32::<BigEndian>(atime).unwrap();
|
||||
buffer.write_u32::<BigEndian>(mtime).unwrap();
|
||||
buffer.write_u32::<BigEndian>(atime)
|
||||
.with_context(|| "serialize attrs atime")?;
|
||||
buffer.write_u32::<BigEndian>(mtime)
|
||||
.with_context(|| "serialize attrs mtime")?;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
buffer.write_u32::<BigEndian>(name.len() as u32).unwrap();
|
||||
buffer.write_all(name.as_bytes()).unwrap();
|
||||
buffer.write_u32::<BigEndian>(value.len() as u32).unwrap();
|
||||
buffer.write_all(value.as_bytes()).unwrap();
|
||||
buffer.write_u32::<BigEndian>(name.len() as u32)
|
||||
.with_context(|| "serialize attrs ext name len")?;
|
||||
buffer.write_all(name.as_bytes())
|
||||
.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>,
|
||||
// ⭐⭐⭐⭐⭐ Phase 4: 添加 client maxpack 限制(参考OpenSSH sftp-server.c)
|
||||
maxpacket: u32, // 来自 SSH_MSG_CHANNEL_OPEN_CONFIRMATION 的 maximum_packet_size
|
||||
/// 限制绝对路径也在 root_dir 之下(chroot 模式)
|
||||
restrict_absolute: bool,
|
||||
}
|
||||
|
||||
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 参数
|
||||
pub fn new(root_dir: PathBuf, maxpacket: u32) -> Self {
|
||||
let canonical_root = root_dir.canonicalize().unwrap_or(root_dir);
|
||||
@@ -268,10 +283,16 @@ impl SftpHandler {
|
||||
root_dir: canonical_root,
|
||||
next_handle_id: 0,
|
||||
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())
|
||||
pub fn handle_request(&mut self, data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.is_empty() {
|
||||
@@ -320,6 +341,21 @@ impl SftpHandler {
|
||||
let version = cursor.read_u32::<BigEndian>()?;
|
||||
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)?;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -364,6 +400,10 @@ impl SftpHandler {
|
||||
|
||||
match 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;
|
||||
self.next_handle_id += 1;
|
||||
|
||||
@@ -424,10 +464,8 @@ impl SftpHandler {
|
||||
if let Some(ref mut file) = handle.file {
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
|
||||
// ⭐⭐⭐⭐⭐ Phase 4: 限制数据大小,不超过 maxpacket - 1024(参考OpenSSH sftp-server.c)
|
||||
// OpenSSH sftp-server.c: process_read() 中限制数据大小为 maxpacket - 1024 bytes
|
||||
// SSH packet overhead: ~1024 bytes (SSH header + SSH_FXP_DATA header)
|
||||
let max_data_size = self.maxpacket - 1024;
|
||||
// ⭐⭐⭐⭐⭐ Phase 4: 限制数据大小,不超过 maxpacket - 1024 和 MAX_XFER_SIZE
|
||||
let max_data_size = std::cmp::min(self.maxpacket.saturating_sub(1024), Self::MAX_XFER_SIZE);
|
||||
let actual_length = std::cmp::min(length, max_data_size);
|
||||
|
||||
info!("SSH_FXP_READ limited: requested={}, actual={}", length, actual_length);
|
||||
@@ -442,7 +480,7 @@ impl SftpHandler {
|
||||
self.build_data_response(id, &buffer)
|
||||
}
|
||||
Err(e) => {
|
||||
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Read error: {}", e))
|
||||
self.build_status_from_io_error(id, &e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -484,7 +522,7 @@ impl SftpHandler {
|
||||
self.build_status_response(id, SftpStatus::SSH_FX_OK, "Write successful")
|
||||
}
|
||||
Err(e) => {
|
||||
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Write error: {}", e))
|
||||
self.build_status_from_io_error(id, &e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -540,7 +578,7 @@ impl SftpHandler {
|
||||
self.build_attrs_response(id, &attrs)
|
||||
}
|
||||
Err(e) => {
|
||||
self.build_status_response(id, SftpStatus::SSH_FX_FAILURE, &format!("Fstat error: {}", e))
|
||||
self.build_status_from_io_error(id, &e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -564,6 +602,10 @@ impl SftpHandler {
|
||||
|
||||
match fs::read_dir(&full_path) {
|
||||
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;
|
||||
self.next_handle_id += 1;
|
||||
|
||||
@@ -582,7 +624,7 @@ impl SftpHandler {
|
||||
self.build_handle_response(id, &handle_id.to_be_bytes())
|
||||
}
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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())])
|
||||
}
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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);
|
||||
|
||||
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)?;
|
||||
|
||||
match File::open(&full_path) {
|
||||
Ok(mut file) => {
|
||||
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)?;
|
||||
|
||||
// 计算MD5哈希
|
||||
@@ -1117,7 +1164,7 @@ impl SftpHandler {
|
||||
self.wrap_sftp_packet(&response)
|
||||
}
|
||||
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);
|
||||
|
||||
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)?;
|
||||
|
||||
match File::open(&full_path) {
|
||||
Ok(mut file) => {
|
||||
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)?;
|
||||
|
||||
// 计算SHA256哈希(使用sha2 crate)
|
||||
@@ -1162,7 +1214,7 @@ impl SftpHandler {
|
||||
self.wrap_sftp_packet(&response)
|
||||
}
|
||||
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);
|
||||
|
||||
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)?;
|
||||
|
||||
match File::open(&full_path) {
|
||||
Ok(mut file) => {
|
||||
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)?;
|
||||
|
||||
// 计算SHA384哈希
|
||||
@@ -1205,7 +1262,7 @@ impl SftpHandler {
|
||||
self.wrap_sftp_packet(&response)
|
||||
}
|
||||
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);
|
||||
|
||||
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)?;
|
||||
|
||||
match File::open(&full_path) {
|
||||
Ok(mut file) => {
|
||||
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)?;
|
||||
|
||||
// 计算SHA512哈希
|
||||
@@ -1248,7 +1310,7 @@ impl SftpHandler {
|
||||
self.wrap_sftp_packet(&response)
|
||||
}
|
||||
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]]),
|
||||
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 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) {
|
||||
Ok(mut read_file) => {
|
||||
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)?;
|
||||
|
||||
// 写入到write_path
|
||||
@@ -1334,72 +1401,112 @@ impl SftpHandler {
|
||||
response.write_u32::<BigEndian>(id)?;
|
||||
|
||||
// 返回复制的字节数
|
||||
response.write_u64::<BigEndian>(read_length)?;
|
||||
response.write_u64::<BigEndian>(actual_length)?;
|
||||
|
||||
self.wrap_sftp_packet(&response)
|
||||
}
|
||||
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) => {
|
||||
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> {
|
||||
info!("resolve_path: input={}, root_dir={:?}", path, self.root_dir);
|
||||
|
||||
let full_path = if path.is_empty() || path == "." {
|
||||
self.root_dir.clone()
|
||||
} else if path.starts_with('/') {
|
||||
// Absolute path: allow access to any path (like /tmp)
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
// Relative path: must be under root_dir
|
||||
self.root_dir.join(path)
|
||||
};
|
||||
|
||||
info!("resolve_path: full_path={:?}", full_path);
|
||||
|
||||
// Security: Only enforce root_dir check for relative paths
|
||||
// Absolute paths are allowed (user can access any path they have filesystem permissions for)
|
||||
if path.starts_with('/') {
|
||||
// Absolute path: no root_dir check, just return canonicalized path if exists
|
||||
let is_absolute = path.starts_with('/');
|
||||
|
||||
// 检查路径遍历:对相对路径始终执行,对绝对路径仅在 restrict_absolute 模式下执行
|
||||
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() {
|
||||
Ok(full_path.canonicalize()?)
|
||||
} else {
|
||||
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>> {
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
// SSH_FXP_VERSION packet
|
||||
buffer.write_u8(SftpPacketType::SSH_FXP_VERSION as u8)?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1468,7 +1575,7 @@ impl SftpHandler {
|
||||
buffer.write_u32::<BigEndian>(long_name.len() as u32)?;
|
||||
buffer.write_all(long_name.as_bytes())?;
|
||||
|
||||
buffer.write_all(&attrs.serialize())?;
|
||||
buffer.write_all(&attrs.serialize()?)?;
|
||||
}
|
||||
|
||||
self.wrap_sftp_packet(&buffer)
|
||||
@@ -1480,10 +1587,30 @@ impl SftpHandler {
|
||||
|
||||
buffer.write_u8(SftpPacketType::SSH_FXP_ATTRS as u8)?;
|
||||
buffer.write_u32::<BigEndian>(id)?;
|
||||
buffer.write_all(&attrs.serialize())?;
|
||||
buffer.write_all(&attrs.serialize()?)?;
|
||||
|
||||
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)
|
||||
@@ -1553,7 +1680,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_sftp_handler_creation() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1573,7 +1700,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_sftp_handle_init() {
|
||||
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 response = handler.handle_request(&init_packet).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user