Skip to main content

bnto_shell/
validate.rs

1// Security validation for shell-run parameters.
2//
3// This module is the security boundary between recipe JSON and system
4// command execution. Every command passes through these checks before
5// reaching ProcessContext::run_command().
6
7/// Shell interpreters that must never be invoked as commands.
8/// Allowing these would let a recipe bypass the argument separation
9/// safety that std::process::Command provides (e.g., `bash -c "rm -rf /"`).
10const 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
25/// Environment variables that must never be set by recipe JSON.
26/// These can hijack the subprocess in dangerous ways:
27///   - LD_PRELOAD / DYLD_*: load arbitrary shared libraries
28///   - PATH: redirect binary resolution to attacker-controlled dirs
29///   - HOME / SHELL / TMPDIR: alter tool behavior unpredictably
30const 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
42/// Startup hooks that make a shell or interpreter run code before the
43/// program's first line (SEC-10). Env values are templated, so a recipe
44/// setting these could turn data back into code:
45///   - SHELLOPTS=xtrace + PS4='$(cmd)' runs code in bash and macOS /bin/sh
46///   - BASH_ENV / ENV / ZDOTDIR (zsh's .zshenv) / FPATH source or autoload files
47///   - IFS, CDPATH, GLOBIGNORE, PROMPT_COMMAND change how a script parses or runs
48///   - NODE_OPTIONS, PERL5OPT/PERL5LIB, PYTHONSTARTUP/PYTHONPATH, RUBYOPT
49///     load modules into interpreters
50const 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
73/// Whole families: dynamic loader controls (`LD_AUDIT`, `DYLD_*`) and bash
74/// exported functions (`BASH_FUNC_ls%%` silently replaces `ls`).
75const DENIED_ENV_PREFIXES: &[&str] = &["LD_", "DYLD_", "BASH_FUNC_"];
76
77/// Default maximum bytes per output file.
78/// Configurable per-node via the `maxOutputSize` parameter (in MB).
79pub const DEFAULT_MAX_OUTPUT_MB: u64 = 500;
80
81/// Convert a megabyte value to bytes for comparison.
82pub fn max_output_bytes(mb: u64) -> usize {
83    (mb as usize) * 1024 * 1024
84}
85
86/// Default timeout in seconds for command execution.
87pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
88
89/// Validate that a command name is safe to execute.
90///
91/// Rejects:
92///   - Empty commands
93///   - Shell interpreters (bash, sh, zsh, powershell, etc.)
94///   - Paths (absolute, relative, or parent traversal)
95///
96/// Commands must be bare binary names resolved via PATH by the OS.
97pub 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    // Reject shell interpreters — they bypass argument separation safety.
103    // Compare the name Windows would run, so `bash.exe` and `cmd.` count.
104    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    // Reject paths — commands must be bare binary names
112    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    // Reject hidden-dot prefix (e.g., ".malicious")
120    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
129/// Whether `cmd` names a shell interpreter, in any spelling Windows runs.
130pub(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
137/// The program name Windows resolves `cmd` to, case-folded: trailing dots
138/// and spaces dropped (Windows ignores them) and a `.exe` / `.com` suffix
139/// removed. On other OSes this only matters for the comparison.
140pub(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
150/// Remove dangerous environment variables from a recipe-provided env map.
151///
152/// Returns the sanitized map (dangerous vars silently stripped).
153/// This is intentionally silent — we don't want to leak information about
154/// which vars are blocked, and benign recipes won't set these.
155pub 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
164/// Whether a recipe may not set `key`, compared case-insensitively.
165fn 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    // SEC-10: startup hooks run code before a script's first line, and env
177    // values are templated, so a recipe must not be able to set them.
178    #[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    // --- Shell denylist ---
237
238    #[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    // --- Path validation ---
283
284    // Windows resolves `bash.exe`, `cmd.` and `pwsh .` to the same shells
285    // the denylist names (SEC-05).
286    #[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    // --- Empty / missing ---
354
355    #[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    // --- Valid commands ---
363
364    #[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    // --- Env var sanitization ---
374
375    #[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}