Skip to content

Commit 33590cb

Browse files
committed
fix(jq): prevent process env pollution in jq builtin
Replace std::env::set_var() calls with a custom env implementation that passes shell variables to jaq via a global variable ($__bashkit_env__). The `env` filter is overridden with `def env: $__bashkit_env__;` which reads from the injected object instead of the process environment. This fixes three issues (TM-INF-013): - Thread-unsafe: set_var is unsound in multi-threaded contexts - Info leak: host process env vars were visible to jq's env filter - Race condition: concurrent jq calls could corrupt each other's env https://claude.ai/code/session_01WZjYqxm5xMPAEe7FSHJkDy
1 parent 1177d91 commit 33590cb

File tree

1 file changed

+131
-40
lines changed
  • crates/bashkit/src/builtins

1 file changed

+131
-40
lines changed

crates/bashkit/src/builtins/jq.rs

Lines changed: 131 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,11 @@ def scan(re; flags): matches(re; "g" + flags)[] | .[0].string;
4848
def scan(re): scan(re; "");
4949
"#;
5050

51-
/// RAII guard that restores process env vars when dropped.
52-
/// Ensures cleanup even on early-return error paths.
53-
struct EnvRestoreGuard(Vec<(String, Option<String>)>);
54-
55-
impl Drop for EnvRestoreGuard {
56-
fn drop(&mut self) {
57-
for (k, old) in &self.0 {
58-
match old {
59-
Some(v) => std::env::set_var(k, v),
60-
None => std::env::remove_var(k),
61-
}
62-
}
63-
}
64-
}
51+
/// Internal global variable name used to pass shell env to jq's `env` filter.
52+
/// SECURITY: Replaces std::env::set_var() which was thread-unsafe and leaked
53+
/// host process env vars. Shell variables are now passed as a jaq global
54+
/// variable, and `def env:` is overridden to read from it.
55+
const ENV_VAR_NAME: &str = "$__bashkit_env__";
6556

6657
/// jq command - JSON processor
6758
pub struct Jq;
@@ -327,9 +318,26 @@ impl Builtin for Jq {
327318
let loader = load::Loader::new(jaq_std::defs().chain(jaq_json::defs()));
328319
let arena = load::Arena::default();
329320

321+
// Build shell env as a JSON object for the custom `env` filter.
322+
// SECURITY: This avoids calling std::env::set_var() which is
323+
// thread-unsafe and leaks host process env vars (TM-INF-013).
324+
// ctx.env takes precedence over ctx.variables (prefix assignments
325+
// like FOO=bar jq ... shadow exported variables).
326+
let env_obj = {
327+
let mut map = serde_json::Map::new();
328+
// variables first, then env overrides (last write wins)
329+
for (k, v) in ctx.variables.iter().chain(ctx.env.iter()) {
330+
map.insert(k.clone(), serde_json::Value::String(v.clone()));
331+
}
332+
serde_json::Value::Object(map)
333+
};
334+
330335
// Prepend compatibility definitions (setpath, leaf_paths, match, scan)
331336
// to override jaq's defaults with jq-compatible behavior.
332-
let compat_filter = format!("{}{}", JQ_COMPAT_DEFS, filter);
337+
// Also override `env` to read from our injected variable instead of
338+
// the process environment.
339+
let env_def = format!("def env: {};", ENV_VAR_NAME);
340+
let compat_filter = format!("{}\n{}\n{}", JQ_COMPAT_DEFS, env_def, filter);
333341
let filter = compat_filter.as_str();
334342

335343
// Parse the filter
@@ -353,13 +361,17 @@ impl Builtin for Jq {
353361
};
354362

355363
// Compile the filter, registering any --arg/--argjson variable names
356-
let var_names: Vec<&str> = var_bindings.iter().map(|(n, _)| n.as_str()).collect();
357-
let compiler = Compiler::default().with_funs(jaq_std::funs().chain(jaq_json::funs()));
358-
let compiler = if var_names.is_empty() {
359-
compiler
360-
} else {
361-
compiler.with_global_vars(var_names.iter().copied())
362-
};
364+
// plus the internal $__bashkit_env__ variable.
365+
// Filter out jaq-std's native `env` filter since we override it with
366+
// a def that reads from our injected global variable.
367+
let mut var_names: Vec<&str> = var_bindings.iter().map(|(n, _)| n.as_str()).collect();
368+
var_names.push(ENV_VAR_NAME);
369+
let native_funs = jaq_std::funs()
370+
.filter(|(name, _, _)| *name != "env")
371+
.chain(jaq_json::funs());
372+
let compiler = Compiler::default()
373+
.with_funs(native_funs)
374+
.with_global_vars(var_names.iter().copied());
363375
let filter = match compiler.compile(modules) {
364376
Ok(f) => f,
365377
Err(errs) => {
@@ -404,22 +416,6 @@ impl Builtin for Jq {
404416
}
405417
};
406418

407-
// Expose bashkit's shell env/variables to the process environment so
408-
// jaq's built-in `env` function (which reads std::env::vars()) works.
409-
// Include both ctx.env (prefix assignments like FOO=bar jq ...)
410-
// and ctx.variables (set via export builtin).
411-
// Uses a drop guard to ensure cleanup on all return paths.
412-
let mut seen = std::collections::HashSet::new();
413-
let mut env_backup: Vec<(String, Option<String>)> = Vec::new();
414-
for (k, v) in ctx.env.iter().chain(ctx.variables.iter()) {
415-
if seen.insert(k.clone()) {
416-
let old = std::env::var(k).ok();
417-
std::env::set_var(k, v);
418-
env_backup.push((k.clone(), old));
419-
}
420-
}
421-
let _env_guard = EnvRestoreGuard(env_backup);
422-
423419
// Track for -e exit status
424420
let mut has_output = false;
425421
let mut all_null_or_false = true;
@@ -428,6 +424,9 @@ impl Builtin for Jq {
428424
// and jaq's input/inputs functions consume from the same source.
429425
let shared_inputs = RcIter::new(inputs_to_process.into_iter().map(Ok::<Val, String>));
430426

427+
// Pre-convert env object to jaq Val once (reused for each input)
428+
let env_val = Val::from(env_obj);
429+
431430
for jaq_input in &shared_inputs {
432431
let jaq_input: Val = match jaq_input {
433432
Ok(v) => v,
@@ -436,11 +435,13 @@ impl Builtin for Jq {
436435
}
437436
};
438437

439-
// Run the filter, passing any --arg/--argjson variable values
440-
let var_vals: Vec<Val> = var_bindings
438+
// Run the filter, passing --arg/--argjson variable values
439+
// plus the env object as the last global variable.
440+
let mut var_vals: Vec<Val> = var_bindings
441441
.iter()
442442
.map(|(_, v)| Val::from(v.clone()))
443443
.collect();
444+
var_vals.push(env_val.clone());
444445
let ctx = Ctx::new(var_vals, &shared_inputs);
445446
for result in filter.run((ctx, jaq_input)) {
446447
match result {
@@ -1233,4 +1234,94 @@ mod tests {
12331234
assert!(result.contains("a,b,c"));
12341235
assert!(result.contains("1,2,3"));
12351236
}
1237+
1238+
// --- Process env pollution tests (issue #410) ---
1239+
1240+
#[tokio::test]
1241+
async fn test_jq_env_no_process_pollution() {
1242+
// Shell variables passed via ctx.env must NOT leak into the process
1243+
// environment. This is a security issue: std::env::set_var() is
1244+
// thread-unsafe and exposes host env vars to jaq's `env` function.
1245+
let unique_key = "BASHKIT_TEST_ENV_POLLUTION_410";
1246+
1247+
// Ensure the var is not already in the process env
1248+
assert!(
1249+
std::env::var(unique_key).is_err(),
1250+
"precondition: {} must not exist in process env",
1251+
unique_key
1252+
);
1253+
1254+
let jq = Jq;
1255+
let fs = Arc::new(InMemoryFs::new());
1256+
let mut vars = HashMap::new();
1257+
let mut cwd = PathBuf::from("/");
1258+
let mut env = HashMap::new();
1259+
env.insert(unique_key.to_string(), "leaked".to_string());
1260+
let args = vec!["-n".to_string(), format!("env.{}", unique_key)];
1261+
1262+
let ctx = Context {
1263+
args: &args,
1264+
env: &env,
1265+
variables: &mut vars,
1266+
cwd: &mut cwd,
1267+
fs,
1268+
stdin: None,
1269+
#[cfg(feature = "http_client")]
1270+
http_client: None,
1271+
#[cfg(feature = "git")]
1272+
git_client: None,
1273+
};
1274+
1275+
let result = jq.execute(ctx).await.unwrap();
1276+
// The jq filter should still see the variable via our custom env
1277+
assert_eq!(result.stdout.trim(), "\"leaked\"");
1278+
1279+
// CRITICAL: The process environment must NOT have been modified
1280+
assert!(
1281+
std::env::var(unique_key).is_err(),
1282+
"process env was polluted with shell variable {}",
1283+
unique_key
1284+
);
1285+
}
1286+
1287+
#[tokio::test]
1288+
async fn test_jq_env_no_host_leak() {
1289+
// Host process env vars should NOT be visible to jq's env filter.
1290+
// Only shell variables from ctx.env/ctx.variables should be exposed.
1291+
let unique_key = "BASHKIT_TEST_HOST_LEAK_410";
1292+
1293+
// Set a host process env var that should NOT be visible to jq
1294+
std::env::set_var(unique_key, "host_secret");
1295+
1296+
let jq = Jq;
1297+
let fs = Arc::new(InMemoryFs::new());
1298+
let mut vars = HashMap::new();
1299+
let mut cwd = PathBuf::from("/");
1300+
let args = vec!["-n".to_string(), format!("env.{}", unique_key)];
1301+
1302+
let ctx = Context {
1303+
args: &args,
1304+
env: &HashMap::new(),
1305+
variables: &mut vars,
1306+
cwd: &mut cwd,
1307+
fs,
1308+
stdin: None,
1309+
#[cfg(feature = "http_client")]
1310+
http_client: None,
1311+
#[cfg(feature = "git")]
1312+
git_client: None,
1313+
};
1314+
1315+
let result = jq.execute(ctx).await.unwrap();
1316+
// Host env var should NOT be visible - should return null
1317+
assert_eq!(
1318+
result.stdout.trim(),
1319+
"null",
1320+
"host env var {} leaked into jq env",
1321+
unique_key
1322+
);
1323+
1324+
// Cleanup
1325+
std::env::remove_var(unique_key);
1326+
}
12361327
}

0 commit comments

Comments
 (0)