Skip to content

Commit 99fbc62

Browse files
committed
fix: consolidated security and robustness improvements
This PR consolidates the following fixes: - #74: Prevent shell injection in restore_script via path escaping - #76: Replace unsafe unwrap() with expect() in init_client - #78: Use secure random temp files in external editor to prevent symlink attacks - #79: Add per-chunk streaming timeout to prevent indefinite hangs Key changes: - Added shell escaping for paths in shell-snapshot restore scripts - Replaced unwrap() with expect() for better error context in exec runner - Use secure random temp files instead of predictable names - Added streaming chunk timeout to prevent hangs during LLM responses
1 parent c398212 commit 99fbc62

4 files changed

Lines changed: 108 additions & 20 deletions

File tree

src/cortex-exec/src/runner.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ const DEFAULT_TIMEOUT_SECS: u64 = 600;
3232
/// Default timeout for a single LLM request (2 minutes).
3333
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 120;
3434

35+
/// Per-chunk timeout during streaming responses.
36+
/// Prevents indefinite hangs when connections stall mid-stream.
37+
/// See cortex_common::http_client for timeout hierarchy documentation.
38+
const STREAMING_CHUNK_TIMEOUT_SECS: u64 = 30;
39+
3540
/// Maximum retries for transient errors.
3641
const MAX_RETRIES: usize = 3;
3742

@@ -187,7 +192,7 @@ impl ExecRunner {
187192
self.client = Some(client);
188193
}
189194

190-
Ok(self.client.as_ref().unwrap().as_ref())
195+
Ok(self.client.as_ref().expect("Client should be initialized in init_client").as_ref())
191196
}
192197

193198
/// Get filtered tool definitions based on options.
@@ -555,7 +560,28 @@ impl ExecRunner {
555560
let mut partial_tool_calls: std::collections::HashMap<String, (String, String)> =
556561
std::collections::HashMap::new();
557562

558-
while let Some(event) = stream.next().await {
563+
loop {
564+
// Apply per-chunk timeout to prevent indefinite hangs when connections stall
565+
let event = match tokio::time::timeout(
566+
Duration::from_secs(STREAMING_CHUNK_TIMEOUT_SECS),
567+
stream.next(),
568+
)
569+
.await
570+
{
571+
Ok(Some(event)) => event,
572+
Ok(None) => break, // Stream ended normally
573+
Err(_) => {
574+
tracing::warn!(
575+
"Stream chunk timeout after {}s",
576+
STREAMING_CHUNK_TIMEOUT_SECS
577+
);
578+
return Err(CortexError::Provider(format!(
579+
"Streaming timeout: no response chunk received within {}s",
580+
STREAMING_CHUNK_TIMEOUT_SECS
581+
)));
582+
}
583+
};
584+
559585
match event? {
560586
ResponseEvent::Delta(delta) => {
561587
if self.options.streaming {

src/cortex-shell-snapshot/src/snapshot.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,13 @@ impl ShellSnapshot {
115115
}
116116

117117
/// Generate a restore script that sources this snapshot.
118+
///
119+
/// The path is properly escaped to prevent shell injection attacks.
120+
/// Paths containing single quotes are escaped using shell-safe quoting.
118121
pub fn restore_script(&self) -> String {
119122
let header = scripts::restore_header(self.metadata.shell_type);
120-
format!(
121-
"{header}\n# Source snapshot\nsource '{}'\n",
122-
self.path.display()
123-
)
123+
let escaped_path = shell_escape_path(&self.path);
124+
format!("{header}\n# Source snapshot\nsource {escaped_path}\n")
124125
}
125126

126127
/// Save the snapshot to disk.
@@ -197,6 +198,27 @@ impl Drop for ShellSnapshot {
197198
}
198199
}
199200

201+
/// Escape a path for safe use in shell commands.
202+
///
203+
/// This function handles paths containing single quotes by using the
204+
/// shell-safe escaping technique: 'path'"'"'with'"'"'quotes'
205+
///
206+
/// For paths without single quotes, simple single-quoting is used.
207+
fn shell_escape_path(path: &Path) -> String {
208+
let path_str = path.display().to_string();
209+
210+
if !path_str.contains('\'') {
211+
// Simple case: no single quotes, just wrap in single quotes
212+
format!("'{}'", path_str)
213+
} else {
214+
// Complex case: escape single quotes using '"'"' technique
215+
// This closes the single-quoted string, adds a double-quoted single quote,
216+
// and reopens the single-quoted string
217+
let escaped = path_str.replace('\'', "'\"'\"'");
218+
format!("'{}'", escaped)
219+
}
220+
}
221+
200222
#[cfg(test)]
201223
mod tests {
202224
use super::*;
@@ -221,4 +243,26 @@ mod tests {
221243
"snapshot_12345678-1234-1234-1234-123456789012.zsh"
222244
);
223245
}
246+
247+
#[test]
248+
fn test_shell_escape_path_simple() {
249+
let path = Path::new("/tmp/test/snapshot.sh");
250+
let escaped = shell_escape_path(path);
251+
assert_eq!(escaped, "'/tmp/test/snapshot.sh'");
252+
}
253+
254+
#[test]
255+
fn test_shell_escape_path_with_single_quotes() {
256+
let path = Path::new("/tmp/test's/snap'shot.sh");
257+
let escaped = shell_escape_path(path);
258+
// Single quotes should be escaped using '"'"' technique
259+
assert_eq!(escaped, "'/tmp/test'\"'\"'s/snap'\"'\"'shot.sh'");
260+
}
261+
262+
#[test]
263+
fn test_shell_escape_path_spaces() {
264+
let path = Path::new("/tmp/test path/snapshot.sh");
265+
let escaped = shell_escape_path(path);
266+
assert_eq!(escaped, "'/tmp/test path/snapshot.sh'");
267+
}
224268
}

src/cortex-tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ walkdir = { workspace = true }
6565

6666
# External editor
6767
which = { workspace = true }
68+
tempfile = { workspace = true }
6869

6970
# Audio notifications
7071
rodio = { workspace = true }

src/cortex-tui/src/external_editor.rs

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,25 @@ pub async fn open_external_editor(initial_content: &str) -> Result<String, Edito
162162
// Get the editor command
163163
let editor_cmd = get_editor()?;
164164

165-
// Create a temporary file
166-
let temp_dir = std::env::temp_dir();
167-
let temp_file = temp_dir.join(format!("cortex_prompt_{}.md", std::process::id()));
168-
169-
// Write initial content
165+
// Create a temporary file with a secure random name to prevent symlink attacks.
166+
// Using tempfile crate ensures proper security (O_EXCL, restricted permissions).
167+
let temp_file = tempfile::Builder::new()
168+
.prefix("cortex_prompt_")
169+
.suffix(".md")
170+
.rand_bytes(16)
171+
.tempfile()
172+
.map_err(EditorError::Io)?;
173+
174+
// Write initial content using the secure file handle
170175
{
171-
let mut file = std::fs::File::create(&temp_file)?;
176+
let mut file = temp_file.reopen().map_err(EditorError::Io)?;
172177
file.write_all(initial_content.as_bytes())?;
173178
file.flush()?;
174179
}
175180

181+
// Keep the temp file alive (don't let it be deleted yet)
182+
let temp_file = temp_file.into_temp_path();
183+
176184
// Parse the editor command
177185
let parts: Vec<&str> = editor_cmd.split_whitespace().collect();
178186
let (editor, args) = match parts.split_first() {
@@ -219,17 +227,25 @@ pub fn open_external_editor_sync(initial_content: &str) -> Result<String, Editor
219227
// Get the editor command
220228
let editor_cmd = get_editor()?;
221229

222-
// Create a temporary file
223-
let temp_dir = std::env::temp_dir();
224-
let temp_file = temp_dir.join(format!("cortex_prompt_{}.md", std::process::id()));
230+
// Create a temporary file with a secure random name to prevent symlink attacks.
231+
// Using tempfile crate ensures proper security (O_EXCL, restricted permissions).
232+
let temp_file = tempfile::Builder::new()
233+
.prefix("cortex_prompt_")
234+
.suffix(".md")
235+
.rand_bytes(16)
236+
.tempfile()
237+
.map_err(EditorError::Io)?;
225238

226-
// Write initial content
239+
// Write initial content using the secure file handle
227240
{
228-
let mut file = std::fs::File::create(&temp_file)?;
241+
let mut file = temp_file.reopen().map_err(EditorError::Io)?;
229242
file.write_all(initial_content.as_bytes())?;
230243
file.flush()?;
231244
}
232245

246+
// Keep the temp file alive (don't let it be deleted yet)
247+
let temp_file = temp_file.into_temp_path();
248+
233249
// Parse the editor command
234250
let parts: Vec<&str> = editor_cmd.split_whitespace().collect();
235251
let (editor, args) = match parts.split_first() {
@@ -264,12 +280,13 @@ pub fn open_external_editor_sync(initial_content: &str) -> Result<String, Editor
264280
Ok(content.trim().to_string())
265281
}
266282

267-
/// Gets the path to the temporary file that would be used.
283+
/// Gets an example path pattern for temporary files.
268284
///
269-
/// Useful for displaying to the user.
285+
/// Note: Actual temp files use random suffixes for security.
286+
/// This function returns a pattern showing the general location.
270287
pub fn get_temp_file_path() -> PathBuf {
271288
let temp_dir = std::env::temp_dir();
272-
temp_dir.join(format!("cortex_prompt_{}.md", std::process::id()))
289+
temp_dir.join("cortex_prompt_XXXXXXXXXXXXXXXX.md")
273290
}
274291

275292
// ============================================================

0 commit comments

Comments
 (0)