Skip to content

Commit 3f751a3

Browse files
committed
fix: Address security issues in SCP handler
- Add symlink escape prevention via canonicalization - Fix TOCTOU race condition in directory creation (atomic mkdir) - Add max line length limit to prevent DoS via memory exhaustion - Mask setuid/setgid/sticky bits from file permissions - Remove unused variable - Improve error messages to not leak internal paths - Add structured security audit logging for blocked operations
1 parent dd72453 commit 3f751a3

1 file changed

Lines changed: 79 additions & 12 deletions

File tree

src/server/scp.rs

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024 * 1024;
6868
/// Buffer size for file transfers (64 KB).
6969
const BUFFER_SIZE: usize = 64 * 1024;
7070

71+
/// Maximum line length for SCP protocol headers (64 KB).
72+
/// This prevents DoS via unbounded buffer growth.
73+
const MAX_LINE_LENGTH: usize = 64 * 1024;
74+
7175
/// SCP operation mode.
7276
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7377
pub enum ScpMode {
@@ -270,6 +274,7 @@ impl ScpHandler {
270274
/// 1. Joining the path with the root directory
271275
/// 2. Normalizing the path (resolving "." and ".." components)
272276
/// 3. Verifying the result is within the root directory
277+
/// 4. If the path exists, canonicalizing to catch symlink attacks
273278
pub fn resolve_path(&self, path: &Path) -> Result<PathBuf> {
274279
let path_str = path.to_string_lossy();
275280

@@ -306,14 +311,48 @@ impl ScpHandler {
306311
// Ensure the resolved path is within the root
307312
if !resolved.starts_with(&self.root_dir) {
308313
tracing::warn!(
314+
event = "path_traversal_attempt",
315+
user = %self.user_info.username,
309316
requested = %path_str,
310317
resolved = %resolved.display(),
311318
root = %self.root_dir.display(),
312-
"Path traversal attempt detected"
319+
"Security: path traversal attempt blocked"
313320
);
314321
anyhow::bail!("Access denied: path outside root");
315322
}
316323

324+
// If the path exists, canonicalize it to catch symlink attacks
325+
// This prevents an attacker from creating symlinks that point outside the root
326+
if resolved.exists() {
327+
match std::fs::canonicalize(&resolved) {
328+
Ok(canonical) => {
329+
if !canonical.starts_with(&self.root_dir) {
330+
tracing::warn!(
331+
event = "symlink_escape_attempt",
332+
user = %self.user_info.username,
333+
requested = %path_str,
334+
resolved = %resolved.display(),
335+
canonical = %canonical.display(),
336+
root = %self.root_dir.display(),
337+
"Security: symlink escape attempt blocked"
338+
);
339+
anyhow::bail!("Access denied: symlink target outside root");
340+
}
341+
// Use the canonical path for existing files
342+
return Ok(canonical);
343+
}
344+
Err(e) => {
345+
// If canonicalization fails, proceed with the resolved path
346+
// This handles broken symlinks and permission issues
347+
tracing::debug!(
348+
path = %resolved.display(),
349+
error = %e,
350+
"Canonicalization failed, using resolved path"
351+
);
352+
}
353+
}
354+
}
355+
317356
tracing::trace!(
318357
requested = %path_str,
319358
resolved = %resolved.display(),
@@ -504,21 +543,32 @@ impl ScpHandler {
504543
anyhow::bail!("Invalid file header: {}", header);
505544
}
506545

507-
let mode = u32::from_str_radix(parts[0], 8)
546+
let raw_mode = u32::from_str_radix(parts[0], 8)
508547
.with_context(|| format!("Invalid mode: {}", parts[0]))?;
548+
// Security: mask mode to only allow standard permission bits
549+
// Prevents setuid (04000), setgid (02000), and sticky (01000) bits
550+
let mode = raw_mode & 0o777;
551+
509552
let size: u64 = parts[1]
510553
.parse()
511554
.with_context(|| format!("Invalid size: {}", parts[1]))?;
512555
let filename = parts[2].trim();
513556

514557
// Security: validate filename
515558
if filename.contains('/') || filename == ".." || filename == "." {
516-
anyhow::bail!("Invalid filename: {}", filename);
559+
anyhow::bail!("Invalid filename");
517560
}
518561

519562
// Check file size limit
520563
if size > MAX_FILE_SIZE {
521-
anyhow::bail!("File too large: {} bytes (max: {} bytes)", size, MAX_FILE_SIZE);
564+
tracing::warn!(
565+
event = "file_size_exceeded",
566+
user = %self.user_info.username,
567+
size = %size,
568+
max_size = %MAX_FILE_SIZE,
569+
"Security: file size limit exceeded"
570+
);
571+
anyhow::bail!("File too large");
522572
}
523573

524574
let target_path = target_dir.join(filename);
@@ -550,7 +600,6 @@ impl ScpHandler {
550600

551601
// Receive file data
552602
let mut remaining = size;
553-
let mut _write_buffer: Vec<u8> = Vec::with_capacity(BUFFER_SIZE);
554603

555604
// First, use any data already in buffer
556605
let buffered = buffer.len().min(remaining as usize);
@@ -663,20 +712,30 @@ impl ScpHandler {
663712
anyhow::bail!("Access denied: path outside root");
664713
}
665714

715+
// Mask mode to only allow standard permission bits (no setuid/setgid/sticky)
716+
let safe_mode = mode & 0o777;
717+
666718
tracing::debug!(
667719
user = %self.user_info.username,
668720
path = %new_dir.display(),
669-
mode = format!("{:04o}", mode),
721+
mode = format!("{:04o}", safe_mode),
670722
"Entering directory"
671723
);
672724

673-
// Create directory if it doesn't exist
674-
if !new_dir.exists() {
675-
fs::create_dir(&new_dir).await?;
676-
#[cfg(unix)]
677-
{
678-
fs::set_permissions(&new_dir, std::fs::Permissions::from_mode(mode)).await?;
725+
// Create directory atomically - handles race conditions safely
726+
// If directory already exists, that's fine; we just continue
727+
match fs::create_dir(&new_dir).await {
728+
Ok(()) => {
729+
#[cfg(unix)]
730+
{
731+
fs::set_permissions(&new_dir, std::fs::Permissions::from_mode(safe_mode))
732+
.await?;
733+
}
734+
}
735+
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
736+
// Directory already exists, which is acceptable
679737
}
738+
Err(e) => return Err(e.into()),
680739
}
681740

682741
Ok(new_dir)
@@ -920,6 +979,14 @@ impl ScpHandler {
920979
return Ok(Some(line));
921980
}
922981

982+
// Check for buffer size limit to prevent DoS via memory exhaustion
983+
if buffer.len() > MAX_LINE_LENGTH {
984+
anyhow::bail!(
985+
"Line too long (max {} bytes) - possible DoS attempt",
986+
MAX_LINE_LENGTH
987+
);
988+
}
989+
923990
// Read more data
924991
match data_rx.recv().await {
925992
Some(data) => {

0 commit comments

Comments
 (0)