Skip to content

Commit 02859da

Browse files
committed
fix(security): Fix PTY fd handling and add shell validation
Priority: CRITICAL/MEDIUM Issues addressed: - Fixed file descriptor leak via mem::forget by using dup() for each stdio fd - Added shell path validation before spawning process - Added bounds checking for window size dimensions (clamp to u16::MAX) - Improved error context for TIOCSWINSZ ioctl failures - Fixed clippy warning for const assertions in tests Review-Iteration: 1
1 parent 4544b00 commit 02859da

2 files changed

Lines changed: 64 additions & 19 deletions

File tree

src/server/pty.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ pub const DEFAULT_COLS: u32 = 80;
5252
/// Default terminal rows.
5353
pub const DEFAULT_ROWS: u32 = 24;
5454

55+
/// Maximum value for terminal dimensions (u16::MAX).
56+
const MAX_DIMENSION: u32 = u16::MAX as u32;
57+
5558
/// PTY configuration from SSH pty_request.
5659
///
5760
/// Contains terminal settings requested by the SSH client.
@@ -92,12 +95,14 @@ impl PtyConfig {
9295
}
9396

9497
/// Create a Winsize struct from this configuration.
98+
///
99+
/// Values exceeding u16::MAX are clamped to u16::MAX to prevent overflow.
95100
pub fn winsize(&self) -> Winsize {
96101
Winsize {
97-
ws_row: self.row_height as u16,
98-
ws_col: self.col_width as u16,
99-
ws_xpixel: self.pix_width as u16,
100-
ws_ypixel: self.pix_height as u16,
102+
ws_row: self.row_height.min(MAX_DIMENSION) as u16,
103+
ws_col: self.col_width.min(MAX_DIMENSION) as u16,
104+
ws_xpixel: self.pix_width.min(MAX_DIMENSION) as u16,
105+
ws_ypixel: self.pix_height.min(MAX_DIMENSION) as u16,
101106
}
102107
}
103108
}
@@ -220,7 +225,7 @@ impl PtyMaster {
220225
let result = unsafe { libc::ioctl(fd.as_raw_fd(), libc::TIOCSWINSZ, winsize) };
221226

222227
if result < 0 {
223-
Err(io::Error::last_os_error()).context("Failed to set window size")
228+
Err(io::Error::last_os_error()).context("Failed to set window size (TIOCSWINSZ ioctl)")
224229
} else {
225230
Ok(())
226231
}
@@ -455,6 +460,18 @@ mod tests {
455460
assert_eq!(winsize.ws_ypixel, 480);
456461
}
457462

463+
#[test]
464+
fn test_pty_config_winsize_overflow_clamping() {
465+
// Test that values exceeding u16::MAX are clamped
466+
let config = PtyConfig::new("xterm".to_string(), 100_000, 100_000, 100_000, 100_000);
467+
let winsize = config.winsize();
468+
469+
assert_eq!(winsize.ws_col, u16::MAX);
470+
assert_eq!(winsize.ws_row, u16::MAX);
471+
assert_eq!(winsize.ws_xpixel, u16::MAX);
472+
assert_eq!(winsize.ws_ypixel, u16::MAX);
473+
}
474+
458475
#[tokio::test]
459476
async fn test_pty_master_open() {
460477
let config = PtyConfig::default();

src/server/shell.rs

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,46 @@ impl ShellSession {
142142
let home_dir = user_info.home_dir.clone();
143143
let username = user_info.username.clone();
144144

145-
// Open slave PTY
145+
// Validate shell path exists
146+
if !shell.exists() {
147+
anyhow::bail!("Shell does not exist: {}", shell.display());
148+
}
149+
150+
// Open slave PTY - we need to duplicate the fd for stdin/stdout/stderr
151+
// since each Stdio::from_raw_fd takes ownership
146152
let slave_file = std::fs::OpenOptions::new()
147153
.read(true)
148154
.write(true)
149155
.open(&slave_path)
150156
.context("Failed to open slave PTY")?;
151157

152-
// Get raw fd for stdio setup
153158
let slave_fd = slave_file.as_raw_fd();
154159

160+
// Duplicate fd for stdin, stdout, stderr
161+
// SAFETY: slave_fd is valid since slave_file is still in scope
162+
let stdin_fd = unsafe { nix::libc::dup(slave_fd) };
163+
let stdout_fd = unsafe { nix::libc::dup(slave_fd) };
164+
let stderr_fd = unsafe { nix::libc::dup(slave_fd) };
165+
166+
if stdin_fd < 0 || stdout_fd < 0 || stderr_fd < 0 {
167+
// Clean up any successful dups before returning error
168+
unsafe {
169+
if stdin_fd >= 0 {
170+
nix::libc::close(stdin_fd);
171+
}
172+
if stdout_fd >= 0 {
173+
nix::libc::close(stdout_fd);
174+
}
175+
if stderr_fd >= 0 {
176+
nix::libc::close(stderr_fd);
177+
}
178+
}
179+
anyhow::bail!("Failed to duplicate slave PTY file descriptor");
180+
}
181+
182+
// Now slave_file can be dropped safely, we have our own fds
183+
drop(slave_file);
184+
155185
let mut cmd = tokio::process::Command::new(&shell);
156186

157187
// Login shell flag
@@ -169,12 +199,12 @@ impl ShellSession {
169199
// Set working directory
170200
cmd.current_dir(&home_dir);
171201

172-
// Set up stdio to use PTY slave
173-
// SAFETY: The file descriptor is valid and we own it via slave_file
202+
// Set up stdio to use PTY slave fds
203+
// SAFETY: Each fd was created via dup() and is uniquely owned
174204
unsafe {
175-
cmd.stdin(Stdio::from_raw_fd(slave_fd));
176-
cmd.stdout(Stdio::from_raw_fd(slave_fd));
177-
cmd.stderr(Stdio::from_raw_fd(slave_fd));
205+
cmd.stdin(Stdio::from_raw_fd(stdin_fd));
206+
cmd.stdout(Stdio::from_raw_fd(stdout_fd));
207+
cmd.stderr(Stdio::from_raw_fd(stderr_fd));
178208
}
179209

180210
// Enable process group management
@@ -200,10 +230,6 @@ impl ShellSession {
200230
// Spawn the process
201231
let child = cmd.spawn().context("Failed to spawn shell process")?;
202232

203-
// Keep slave_file alive until here to prevent fd from being closed too early
204-
// The fd is now owned by the child process, so we can safely forget the file
205-
std::mem::forget(slave_file);
206-
207233
tracing::info!(
208234
shell = %shell.display(),
209235
home = %home_dir.display(),
@@ -420,8 +446,10 @@ mod tests {
420446

421447
#[test]
422448
fn test_io_buffer_size() {
423-
// Verify buffer size is reasonable
424-
assert!(IO_BUFFER_SIZE >= 4096);
425-
assert!(IO_BUFFER_SIZE <= 65536);
449+
// Verify buffer size is reasonable using const assertions
450+
const _: () = {
451+
assert!(IO_BUFFER_SIZE >= 4096);
452+
assert!(IO_BUFFER_SIZE <= 65536);
453+
};
426454
}
427455
}

0 commit comments

Comments
 (0)