1const SHELL_DENYLIST: &[&str] = &[
11 "bash",
12 "sh",
13 "zsh",
14 "fish",
15 "dash",
16 "csh",
17 "tcsh",
18 "ksh",
19 "cmd",
20 "cmd.exe",
21 "powershell",
22 "pwsh",
23];
24
25const DENIED_ENV_VARS: &[&str] = &[
31 "LD_PRELOAD",
32 "LD_LIBRARY_PATH",
33 "DYLD_INSERT_LIBRARIES",
34 "DYLD_LIBRARY_PATH",
35 "DYLD_FRAMEWORK_PATH",
36 "PATH",
37 "HOME",
38 "SHELL",
39 "TMPDIR",
40];
41
42const STARTUP_ENV_VARS: &[&str] = &[
51 "BASH_ENV",
52 "ENV",
53 "SHELLOPTS",
54 "BASHOPTS",
55 "PS1",
56 "PS2",
57 "PS3",
58 "PS4",
59 "PROMPT_COMMAND",
60 "IFS",
61 "CDPATH",
62 "GLOBIGNORE",
63 "ZDOTDIR",
64 "FPATH",
65 "NODE_OPTIONS",
66 "PERL5OPT",
67 "PERL5LIB",
68 "PYTHONSTARTUP",
69 "PYTHONPATH",
70 "RUBYOPT",
71];
72
73const DENIED_ENV_PREFIXES: &[&str] = &["LD_", "DYLD_", "BASH_FUNC_"];
76
77pub const DEFAULT_MAX_OUTPUT_MB: u64 = 500;
80
81pub fn max_output_bytes(mb: u64) -> usize {
83 (mb as usize) * 1024 * 1024
84}
85
86pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
88
89pub fn validate_command(cmd: &str) -> Result<(), String> {
98 if cmd.is_empty() {
99 return Err("command must not be empty".to_string());
100 }
101
102 if is_shell(cmd) {
105 return Err(format!(
106 "'{cmd}' is a shell interpreter and cannot be used as a command. \
107 Use the specific tool directly (e.g., 'ffmpeg', 'yt-dlp')"
108 ));
109 }
110
111 if cmd.contains('/') || cmd.contains('\\') {
113 return Err(format!(
114 "'{cmd}' contains a path separator. Commands must be bare binary \
115 names (e.g., 'ffmpeg', not './ffmpeg' or '/usr/bin/ffmpeg')"
116 ));
117 }
118
119 if cmd.starts_with('.') {
121 return Err(format!(
122 "'{cmd}' starts with a dot. Commands must be bare binary names"
123 ));
124 }
125
126 Ok(())
127}
128
129pub(crate) fn is_shell(cmd: &str) -> bool {
131 let stem = program_stem(cmd);
132 SHELL_DENYLIST
133 .iter()
134 .any(|shell| stem == program_stem(shell))
135}
136
137pub(crate) fn program_stem(cmd: &str) -> String {
141 let lower = cmd.to_lowercase();
142 let name = lower.trim_end_matches(['.', ' ']);
143 let name = name
144 .strip_suffix(".exe")
145 .or_else(|| name.strip_suffix(".com"))
146 .unwrap_or(name);
147 name.trim_end_matches(['.', ' ']).to_string()
148}
149
150pub fn sanitize_env(
156 env: &serde_json::Map<String, serde_json::Value>,
157) -> serde_json::Map<String, serde_json::Value> {
158 env.iter()
159 .filter(|(key, _)| !is_denied_env_var(key))
160 .map(|(k, v)| (k.clone(), v.clone()))
161 .collect()
162}
163
164fn is_denied_env_var(key: &str) -> bool {
166 let key = key.to_uppercase();
167 DENIED_ENV_VARS.contains(&key.as_str())
168 || STARTUP_ENV_VARS.contains(&key.as_str())
169 || DENIED_ENV_PREFIXES.iter().any(|p| key.starts_with(p))
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
179 fn strips_shell_and_interpreter_startup_variables() {
180 let names = [
181 "BASH_ENV",
182 "ENV",
183 "SHELLOPTS",
184 "BASHOPTS",
185 "PS1",
186 "PS2",
187 "PS3",
188 "PS4",
189 "PROMPT_COMMAND",
190 "IFS",
191 "CDPATH",
192 "GLOBIGNORE",
193 "ZDOTDIR",
194 "FPATH",
195 "BASH_FUNC_ls%%",
196 "bash_func_cp()",
197 "NODE_OPTIONS",
198 "PERL5OPT",
199 "PERL5LIB",
200 "PYTHONSTARTUP",
201 "PYTHONPATH",
202 "RUBYOPT",
203 "LD_AUDIT",
204 "ld_bind_now",
205 "Shellopts",
206 ];
207 let env: serde_json::Map<_, _> = names
208 .iter()
209 .map(|n| (n.to_string(), serde_json::json!("x")))
210 .collect();
211 let kept = sanitize_env(&env);
212 assert!(
213 kept.is_empty(),
214 "kept: {:?}",
215 kept.keys().collect::<Vec<_>>()
216 );
217 }
218
219 #[test]
220 fn keeps_ordinary_variables_that_merely_resemble_startup_ones() {
221 let names = [
222 "ENVIRONMENT",
223 "PS_TOKEN",
224 "MY_IFS",
225 "NODE_ENV",
226 "PYTHONUNBUFFERED",
227 "API_KEY",
228 ];
229 let env: serde_json::Map<_, _> = names
230 .iter()
231 .map(|n| (n.to_string(), serde_json::json!("x")))
232 .collect();
233 assert_eq!(sanitize_env(&env).len(), names.len());
234 }
235
236 #[test]
239 fn rejects_bash_command() {
240 let result = validate_command("bash");
241 assert!(result.is_err());
242 assert!(result.unwrap_err().contains("shell interpreter"));
243 }
244
245 #[test]
246 fn rejects_sh_command() {
247 assert!(validate_command("sh").is_err());
248 }
249
250 #[test]
251 fn rejects_zsh_command() {
252 assert!(validate_command("zsh").is_err());
253 }
254
255 #[test]
256 fn rejects_powershell_command() {
257 assert!(validate_command("powershell").is_err());
258 }
259
260 #[test]
261 fn rejects_cmd_exe_command() {
262 assert!(validate_command("cmd.exe").is_err());
263 }
264
265 #[test]
266 fn rejects_pwsh_command() {
267 assert!(validate_command("pwsh").is_err());
268 }
269
270 #[test]
271 fn rejects_fish_command() {
272 assert!(validate_command("fish").is_err());
273 }
274
275 #[test]
276 fn rejects_shell_case_insensitive() {
277 assert!(validate_command("BASH").is_err());
278 assert!(validate_command("Sh").is_err());
279 assert!(validate_command("PowerShell").is_err());
280 }
281
282 #[test]
287 fn rejects_windows_spellings_of_shells() {
288 for cmd in [
289 "bash.exe",
290 "sh.exe",
291 "zsh.EXE",
292 "PowerShell.exe",
293 "pwsh.exe",
294 "cmd.com",
295 "cmd.",
296 "bash.",
297 "pwsh .",
298 "bash.exe.",
299 "powershell.exe ",
300 ] {
301 let err = validate_command(cmd).expect_err(cmd);
302 assert!(err.contains("shell interpreter"), "{cmd}: {err}");
303 }
304 }
305
306 #[test]
307 fn allows_tools_whose_names_only_start_like_shells() {
308 for cmd in [
309 "shellcheck",
310 "bashate",
311 "cmdtool",
312 "python.exe",
313 "ffmpeg.exe",
314 "sh-tool",
315 ] {
316 assert!(validate_command(cmd).is_ok(), "{cmd} refused");
317 }
318 }
319
320 #[test]
321 fn rejects_relative_path_command() {
322 let result = validate_command("./malicious");
323 assert!(result.is_err());
324 assert!(result.unwrap_err().contains("path separator"));
325 }
326
327 #[test]
328 fn rejects_absolute_path_command() {
329 let result = validate_command("/tmp/evil");
330 assert!(result.is_err());
331 assert!(result.unwrap_err().contains("path separator"));
332 }
333
334 #[test]
335 fn rejects_parent_traversal_command() {
336 let result = validate_command("../../../bin/evil");
337 assert!(result.is_err());
338 }
339
340 #[test]
341 fn rejects_windows_path_command() {
342 let result = validate_command("C:\\Windows\\System32\\cmd.exe");
343 assert!(result.is_err());
344 }
345
346 #[test]
347 fn rejects_dot_prefixed_command() {
348 let result = validate_command(".hidden-binary");
349 assert!(result.is_err());
350 assert!(result.unwrap_err().contains("starts with a dot"));
351 }
352
353 #[test]
356 fn rejects_empty_command() {
357 let result = validate_command("");
358 assert!(result.is_err());
359 assert!(result.unwrap_err().contains("must not be empty"));
360 }
361
362 #[test]
365 fn accepts_legitimate_binaries() {
366 assert!(validate_command("ffmpeg").is_ok());
367 assert!(validate_command("yt-dlp").is_ok());
368 assert!(validate_command("convert").is_ok());
369 assert!(validate_command("curl").is_ok());
370 assert!(validate_command("python3").is_ok());
371 }
372
373 #[test]
376 fn strips_ld_preload_from_env() {
377 let mut env = serde_json::Map::new();
378 env.insert(
379 "LD_PRELOAD".to_string(),
380 serde_json::Value::String("/tmp/evil.so".to_string()),
381 );
382 env.insert(
383 "RUST_LOG".to_string(),
384 serde_json::Value::String("debug".to_string()),
385 );
386 let sanitized = sanitize_env(&env);
387 assert!(!sanitized.contains_key("LD_PRELOAD"));
388 assert!(sanitized.contains_key("RUST_LOG"));
389 }
390
391 #[test]
392 fn strips_path_from_env() {
393 let mut env = serde_json::Map::new();
394 env.insert(
395 "PATH".to_string(),
396 serde_json::Value::String("/tmp/malicious".to_string()),
397 );
398 let sanitized = sanitize_env(&env);
399 assert!(!sanitized.contains_key("PATH"));
400 }
401
402 #[test]
403 fn strips_dyld_vars_from_env() {
404 let mut env = serde_json::Map::new();
405 env.insert(
406 "DYLD_INSERT_LIBRARIES".to_string(),
407 serde_json::Value::String("/tmp/evil.dylib".to_string()),
408 );
409 env.insert(
410 "DYLD_FALLBACK_LIBRARY_PATH".to_string(),
411 serde_json::Value::String("/tmp".to_string()),
412 );
413 let sanitized = sanitize_env(&env);
414 assert!(!sanitized.contains_key("DYLD_INSERT_LIBRARIES"));
415 assert!(!sanitized.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
416 }
417
418 #[test]
419 fn strips_home_shell_tmpdir() {
420 let mut env = serde_json::Map::new();
421 env.insert("HOME".into(), serde_json::Value::String("/tmp".into()));
422 env.insert("SHELL".into(), serde_json::Value::String("/bin/sh".into()));
423 env.insert("TMPDIR".into(), serde_json::Value::String("/tmp".into()));
424 let sanitized = sanitize_env(&env);
425 assert!(sanitized.is_empty());
426 }
427
428 #[test]
429 fn allows_safe_env_vars() {
430 let mut env = serde_json::Map::new();
431 env.insert(
432 "RUST_LOG".to_string(),
433 serde_json::Value::String("debug".to_string()),
434 );
435 env.insert(
436 "FFMPEG_THREADS".to_string(),
437 serde_json::Value::String("4".to_string()),
438 );
439 let sanitized = sanitize_env(&env);
440 assert_eq!(sanitized.len(), 2);
441 assert!(sanitized.contains_key("RUST_LOG"));
442 assert!(sanitized.contains_key("FFMPEG_THREADS"));
443 }
444
445 #[test]
446 fn env_var_denylist_is_case_insensitive() {
447 let mut env = serde_json::Map::new();
448 env.insert(
449 "ld_preload".to_string(),
450 serde_json::Value::String("/tmp/evil.so".to_string()),
451 );
452 env.insert(
453 "path".to_string(),
454 serde_json::Value::String("/tmp".to_string()),
455 );
456 let sanitized = sanitize_env(&env);
457 assert!(sanitized.is_empty());
458 }
459}