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