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.
97#[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    // Reject shell interpreters — they bypass argument separation safety.
104    // Compare the name Windows would run, so `bash.exe` and `cmd.` count.
105    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    // Reject paths — commands must be bare binary names
113    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    // Reject hidden-dot prefix (e.g., ".malicious")
121    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
130/// Whether `cmd` names a shell interpreter, in any spelling Windows runs.
131pub(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
138/// The program name Windows resolves `cmd` to, case-folded: trailing dots
139/// and spaces dropped (Windows ignores them) and a `.exe` / `.com` suffix
140/// removed. On other OSes this only matters for the comparison.
141pub(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
151/// Remove dangerous environment variables from a recipe-provided env map.
152///
153/// Returns the sanitized map (dangerous vars silently stripped).
154/// This is intentionally silent — we don't want to leak information about
155/// which vars are blocked, and benign recipes won't set these.
156pub 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
165/// Whether a recipe may not set `key`, compared case-insensitively.
166fn 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    // SEC-10: startup hooks run code before a script's first line, and env
178    // values are templated, so a recipe must not be able to set them.
179    #[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    // --- Shell denylist ---
238
239    #[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    // --- Path validation ---
284
285    // Windows resolves `bash.exe`, `cmd.` and `pwsh .` to the same shells
286    // the denylist names (SEC-05).
287    #[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    // --- Empty / missing ---
355
356    #[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    // --- Valid commands ---
364
365    #[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    // --- Env var sanitization ---
375
376    #[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}