diff --git a/docs/cli/trust.md b/docs/cli/trust.md index 0cf6e09207..9b555c7124 100644 --- a/docs/cli/trust.md +++ b/docs/cli/trust.md @@ -12,6 +12,13 @@ parsing `mise.toml`. Without trust, mise may prompt, skip the config in some discovery paths, fail with an untrusted-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. +Safe config files do not require trust: files that only contain +`min_version`, `[tools]` entries with plain version strings (or arrays +of them), and `[tasks]` (no templates and no tool options) are loaded +without prompting, since nothing in them executes code at load time — +tools install and tasks run only on explicit commands like `mise install` +or `mise run`. + ## Arguments ### `[CONFIG_FILE]` diff --git a/docs/faq.md b/docs/faq.md index 4ab9cdd203..88b3d2bfca 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -241,7 +241,12 @@ mise upgrade --bump node ## My config file is being ignored / `mise trust` issues -mise requires you to trust config files that were not created by you. Common issues: +mise requires you to trust config files that were not created by you. Safe config files — +those that only contain `min_version`, `[tools]` entries with plain version strings (or +arrays of them), and `[tasks]` (no templates and no tool options) — are loaded without trust, since nothing in +them executes code at load time: tools install and tasks run only on explicit commands like +`mise install` or `mise run`. Everything else (env vars, hooks, settings, aliases, templates, +tool options) requires trust. Common issues: - **Accidentally denied trust**: If mise prompted you to trust a file and you said no, it gets added to the ignore list. Check the `ignored-configs` directory in your diff --git a/e2e/config/test_trust_safe_config b/e2e/config/test_trust_safe_config new file mode 100644 index 0000000000..d6e85a45b8 --- /dev/null +++ b/e2e/config/test_trust_safe_config @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# Safe mise.toml files (min_version, [tools] with plain version strings, and +# [tasks]) can be loaded without trusting them first. Anything that can execute +# code at load time or change mise's behavior still requires trust. + +export MISE_TRUSTED_CONFIG_PATHS="" +unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true + +# Fail if any trust marker file/symlink exists under trusted-configs. This is +# precise (a stale empty dir won't mask a wrongly-written marker) unlike a bare +# directory-existence check. +assert_no_trust_marker() { + local found + found=$(find "$MISE_STATE_DIR/trusted-configs" -mindepth 1 \( -type f -o -type l \) 2>/dev/null || true) + [[ -z $found ]] || fail "$1 (found trust markers: $found)" +} + +mkdir -p project +cd project || exit 1 + +cat <<'EOF' >mise.toml +min_version = "2024.1.1" + +[tools] +tiny = "3.1.0" + +[tasks.hi] +run = "echo task-ran-without-trust" +EOF + +# loads without prompting and without creating a trust marker +MISE_YES=0 mise install tiny +assert_contains "MISE_YES=0 mise ls tiny" "3.1.0" +assert_no_trust_marker "safe config should not be silently trusted" + +# config tasks only execute on explicit `mise run`, so they don't need trust +assert_contains "MISE_YES=0 mise run hi" "task-ran-without-trust" + +# template-free file tasks are inert at load time and runnable without trust +mkdir -p mise-tasks +cat <<'EOF' >mise-tasks/filetask +#!/usr/bin/env bash +#MISE description="a plain file task" +echo file-task-ran-without-trust +EOF +chmod +x mise-tasks/filetask +assert_contains "MISE_YES=0 mise run filetask" "file-task-ran-without-trust" +assert_no_trust_marker "file task should not require or create trust" + +# a template in a file task header renders at load time, so it requires trust +cat <<'EOF' >mise-tasks/evil +#!/usr/bin/env bash +#MISE description="{{ exec(command='echo PWNED > marker') }}" +echo evil +EOF +chmod +x mise-tasks/evil +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f marker ]] || fail "file task header template executed without trust" +echo "$output" | grep -qi "trust" || fail "expected trust error for templated file task, got: $output" +rm mise-tasks/evil + +# a template in a version string requires trust and must not execute +cat <<'EOF' >mise.toml +[tools] +tiny = "{{ exec(command='echo PWNED > marker') }}" +EOF +output=$(MISE_YES=0 mise ls 2>&1 || true) +[[ ! -f marker ]] || fail "template executed in untrusted config" +echo "$output" | grep -qi "trust" || fail "expected trust error for template, got: $output" + +# a template in a task renders at load time, so it requires trust +cat <<'EOF' >mise.toml +[tasks.hi] +run = "echo hi" +description = "{{ exec(command='echo PWNED > marker') }}" +EOF +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f marker ]] || fail "task template executed in untrusted config" +echo "$output" | grep -qi "trust" || fail "expected trust error for task template, got: $output" + +# escaped Tera delimiters ({ == '{', } == '}') decode to a template +# after TOML parsing; this must not bypass the safety check and exec +printf '[tools]\ntiny = "\\u007b\\u007b exec(command=%stouch marker%s) \\u007d\\u007d"\n' "'" "'" >mise.toml +output=$(MISE_YES=0 mise ls 2>&1 || true) +[[ ! -f marker ]] || fail "escaped template executed in untrusted config (tools)" +echo "$output" | grep -qi "trust" || fail "expected trust error for escaped tools template, got: $output" + +printf '[tasks.hi]\nrun = "echo hi"\ndescription = "\\u007b\\u007b exec(command=%stouch marker%s) \\u007d\\u007d"\n' "'" "'" >mise.toml +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f marker ]] || fail "escaped template executed in untrusted config (tasks)" +echo "$output" | grep -qi "trust" || fail "expected trust error for escaped task template, got: $output" + +# tool options can run code (postinstall) or alter installs (install_env) +cat <<'EOF' >mise.toml +[tools] +tiny = { version = "3.1.0", postinstall = "touch marker" } +EOF +output=$(MISE_YES=0 mise ls 2>&1 || true) +echo "$output" | grep -qi "trust" || fail "expected trust error for tool options, got: $output" + +# env vars require trust +cat <<'EOF' >mise.toml +[env] +FOO = "bar" +EOF +output=$(MISE_YES=0 mise env 2>&1 || true) +echo "$output" | grep -qi "trust" || fail "expected trust error for env, got: $output" + +# unsafe configs still load normally once trusted +mise trust +assert_contains "mise env" "FOO=bar" diff --git a/e2e/config/test_trust_safe_config_tracked b/e2e/config/test_trust_safe_config_tracked new file mode 100644 index 0000000000..9800334901 --- /dev/null +++ b/e2e/config/test_trust_safe_config_tracked @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# A safe (untrusted) mise.toml is tracked and its tool pins remain visible +# through tracked-config reload paths like `mise ls --all-sources`, even when +# invoked from a different directory. Regression: tracked-config loading used +# to skip any config without a trust marker, which safe configs don't create. + +export MISE_TRUSTED_CONFIG_PATHS="" +unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true + +mkdir -p proj other +cat <<'EOF' >proj/mise.toml +[tools] +tiny = "3.1.0" +EOF + +# install from the safe config (loads + tracks it, no trust prompt/marker) +(cd proj && MISE_YES=0 mise install tiny) + +# from an unrelated directory, the tracked safe config's pin is still listed +cd other || exit 1 +assert_contains "MISE_YES=0 mise ls --all-sources 2>&1" "proj/mise.toml" +assert_contains "MISE_YES=0 mise ls --all-sources 2>&1" "tiny" diff --git a/e2e/tasks/test_task_include_trust_escaped b/e2e/tasks/test_task_include_trust_escaped new file mode 100644 index 0000000000..4fd3881811 --- /dev/null +++ b/e2e/tasks/test_task_include_trust_escaped @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +# Task include files (mise-tasks/*.toml and #MISE script headers) that hide a +# Tera template behind escaped delimiters ({ -> '{', } -> '}') must +# still require trust: the escapes decode to a real template that renders — and +# can exec() — at load time. A raw-text scan alone would miss them. This is the +# non-paranoid case (paranoid mode trivially requires trust for everything). + +export MISE_TRUSTED_CONFIG_PATHS="" +unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true + +mkdir -p mise-tasks + +# 1. escaped template in a .toml task file +printf '[evil]\nrun = "echo hi"\ndescription = "\\u007b\\u007b exec(command=%stouch toml-marker%s) \\u007d\\u007d"\n' "'" "'" >mise-tasks/ci.toml +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f toml-marker ]] || fail "escaped template executed from untrusted .toml task include" +echo "$output" | grep -qi "not trusted" || fail "expected trust error for escaped .toml include, got: $output" +rm -f mise-tasks/ci.toml + +# 2. escaped template in a #MISE script header (printf so the \u escapes reach +# the file literally, exercising the decoded-header path rather than the +# raw-text gate) +{ + printf '#!/usr/bin/env bash\n' + printf '#MISE description="\\u007b\\u007b exec(command=%stouch script-marker%s) \\u007d\\u007d"\n' "'" "'" + printf 'echo hi\n' +} >mise-tasks/evil.sh +chmod +x mise-tasks/evil.sh +output=$(MISE_YES=0 mise tasks 2>&1 || true) +[[ ! -f script-marker ]] || fail "escaped template executed from untrusted script header" +echo "$output" | grep -qi "not trusted" || fail "expected trust error for escaped script header, got: $output" +rm -f mise-tasks/evil.sh + +# 3. sanity: a plain (template-free) file task still loads without trust +cat <<'EOF' >mise-tasks/hello.sh +#!/usr/bin/env bash +#MISE description="a plain task" +echo plain-task-ran +EOF +chmod +x mise-tasks/hello.sh +assert_contains "MISE_YES=0 mise run hello" "plain-task-ran" diff --git a/e2e/tasks/test_task_monorepo_trust b/e2e/tasks/test_task_monorepo_trust index f05b8ac8a2..53d84dbd28 100755 --- a/e2e/tasks/test_task_monorepo_trust +++ b/e2e/tasks/test_task_monorepo_trust @@ -43,10 +43,14 @@ assert_not_contains "mise tasks ls --all 2>&1" "Trust them" assert_contains "mise run '//projects/frontend:build'" "frontend build" assert_contains "mise run '//projects/frontend/components:test'" "component test" -# Test that trust warnings DO appear for configs outside monorepo -# Create a parent directory with its own config (not part of monorepo) +# Test that trust warnings DO appear for unsafe configs outside monorepo +# (tasks-only configs are safe and load without trust, so use [env] to make +# this one require trust) mkdir -p ../parent-dir cat <../parent-dir/mise.toml +[env] +FOO = "bar" + [tasks.parent-task] run = 'echo "parent task"' EOF diff --git a/e2e/tasks/test_task_untrusted_config_error b/e2e/tasks/test_task_untrusted_config_error index 2526b875d5..b5a19d43ff 100644 --- a/e2e/tasks/test_task_untrusted_config_error +++ b/e2e/tasks/test_task_untrusted_config_error @@ -5,18 +5,29 @@ # Ensure we start with a clean slate - no trusted configs export MISE_TRUSTED_CONFIG_PATHS="" +unset CI GITHUB_ACTIONS GITHUB_ACTION 2>/dev/null || true -# Create a config file with a task +# A config that only defines tasks is safe: tasks execute nothing until the +# user explicitly runs one, so no trust is required cat <mise.toml [tasks.make] run = "echo 'hello from task'" EOF -# Running a task from an untrusted config should give a helpful error -# It should either: +assert_contains "MISE_YES=0 mise run make" "hello from task" + +# Unsafe configs (here: [env]) still require trust. Running a task from one +# should give a helpful error message: # 1. Prompt the user to trust the config (when interactive), or # 2. Show a clear "config not trusted" error (not "no tasks defined") -# +cat <mise.toml +[env] +FOO = "bar" + +[tasks.make] +run = "echo 'hello from task'" +EOF + # This test verifies we don't get the misleading "no tasks defined" error output=$(MISE_YES=0 mise run make 2>&1 || true) diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 752a9c9281..ab9421abc8 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -3402,6 +3402,13 @@ that may execute code or affect the environment. mise checks trust before parsing `mise.toml`. Without trust, mise may prompt, skip the config in some discovery paths, fail with an untrusted\-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. + +Safe config files do not require trust: files that only contain +`min_version`, `[tools]` entries with plain version strings (or arrays +of them), and `[tasks]` (no templates and no tool options) are loaded +without prompting, since nothing in them executes code at load time — +tools install and tasks run only on explicit commands like `mise install` +or `mise run`. .PP \fBUsage:\fR mise trust [OPTIONS] [] .PP diff --git a/mise.usage.kdl b/mise.usage.kdl index a35456a67c..9474926a8d 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -3503,6 +3503,13 @@ that may execute code or affect the environment. mise checks trust before parsing `mise.toml`. Without trust, mise may prompt, skip the config in some discovery paths, fail with an untrusted-config error when it cannot prompt, or assume trust in detected CI unless paranoid mode is enabled. + +Safe config files do not require trust: files that only contain +`min_version`, `[tools]` entries with plain version strings (or arrays +of them), and `[tasks]` (no templates and no tool options) are loaded +without prompting, since nothing in them executes code at load time — +tools install and tasks run only on explicit commands like `mise install` +or `mise run`. """# after_long_help #""" Examples: diff --git a/src/cli/trust.rs b/src/cli/trust.rs index a18cccf2d2..626d860975 100644 --- a/src/cli/trust.rs +++ b/src/cli/trust.rs @@ -18,6 +18,13 @@ use itertools::Itertools; /// parsing `mise.toml`. Without trust, mise may prompt, skip the config in some /// discovery paths, fail with an untrusted-config error when it cannot prompt, /// or assume trust in detected CI unless paranoid mode is enabled. +/// +/// Safe config files do not require trust: files that only contain +/// `min_version`, `[tools]` entries with plain version strings (or arrays +/// of them), and `[tasks]` (no templates and no tool options) are loaded +/// without prompting, since nothing in them executes code at load time — +/// tools install and tasks run only on explicit commands like `mise install` +/// or `mise run`. #[derive(Debug, clap::Args)] #[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub struct Trust { diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index c9dfa7b94f..3e3a1eae39 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -18,11 +18,13 @@ use toml_edit::{Array, DocumentMut, InlineTable, Item, Key, Value, table, value} use versions::Versioning; use crate::cli::args::{BackendArg, ToolVersionType}; -use crate::config::config_file::{ConfigFile, TaskConfig, config_trust_root, trust, trust_check}; +use crate::config::config_file::{ + ConfigFile, TaskConfig, config_trust_root, is_ignored, trust, trust_check, +}; use crate::config::config_file::{config_root, toml::deserialize_arr}; use crate::config::env_directive::{AgeFormat, EnvDirective, EnvDirectiveOptions, RequiredValue}; use crate::config::settings::SettingsPartial; -use crate::config::{Alias, AliasMap, Config}; +use crate::config::{Alias, AliasMap, Config, Settings}; use crate::deps::DepsConfig; use crate::env_diff::EnvMap; use crate::file::{create_dir_all, display_path}; @@ -273,7 +275,9 @@ impl MiseToml { } pub fn from_str(body: &str, path: &Path) -> eyre::Result { - trust_check(path)?; + if !Self::is_trust_exempt(body, path) { + trust_check(path)?; + } trace!("parsing: {}", display_path(path)); let des = toml::Deserializer::parse(body).map_err(|e| toml_parse_error(&e, body, path))?; let de_res = serde_ignored::deserialize(des, |p| { @@ -302,6 +306,32 @@ impl MiseToml { Ok(rf) } + /// Whether the config file at `path` loads without trust (see + /// [`Self::is_trust_exempt`]). Returns false for unreadable files and for + /// non-mise.toml files (e.g. `.tool-versions`), which have their own flow. + pub fn path_is_trust_exempt(path: &Path) -> bool { + file::read_to_string(path).is_ok_and(|body| Self::is_trust_exempt(&body, path)) + } + + /// Whether this config body can be loaded without trusting the file. + /// + /// Safe configs cannot execute code or change mise's behavior beyond + /// requesting tool versions and defining tasks, so there is nothing to + /// gate behind a trust prompt. Anything else — env vars, hooks, settings, + /// aliases, templates, tool options like `postinstall`/`install_env` — + /// still requires trust. + fn is_trust_exempt(body: &str, path: &Path) -> bool { + if Settings::try_get().is_ok_and(|settings| settings.paranoid) { + return false; + } + // configs the user chose to ignore should stay unloaded rather than + // becoming loadable because their content happens to be safe + if is_ignored(&config_trust_root(path)) || is_ignored(path) { + return false; + } + is_safe_config_body(body) + } + fn doc(&self) -> eyre::Result { self.doc .lock() @@ -1947,6 +1977,67 @@ impl<'de> de::Deserialize<'de> for Alias { } } +/// A config body is safe to load without trust when nothing in it can execute +/// code at load time or change mise's behavior without an explicit user +/// action: +/// - `min_version` is inert +/// - `[tools]` entries with plain version strings only matter when the user +/// runs something like `mise install`. Entries with options (tables) are +/// excluded because options like `postinstall` and `install_env` run code +/// or alter the install environment. +/// - `[tasks]` definitions are inert until the user explicitly runs one +/// - no Tera template syntax anywhere — templates render while config and +/// tasks load and can run arbitrary commands via exec() +fn is_safe_config_body(body: &str) -> bool { + // Fast reject: literal Tera delimiters in the raw text. + if contains_template_syntax(body) { + return false; + } + let Ok(toml::Value::Table(table)) = toml::from_str::(body) else { + // let the normal trust + parse flow handle invalid TOML + return false; + }; + // The raw-body check above misses escaped delimiters that TOML decodes, + // e.g. `"{{ exec(...) }}"` becomes `{{ exec(...) }}` + // after parsing and would still render via Tera. Re-check every decoded + // string (keys and values, at any depth) so no exec()-capable template + // can slip through into tool versions or task fields. + if toml_table_has_template(&table) { + return false; + } + table.iter().all(|(key, value)| match key.as_str() { + "min_version" | "tasks" => true, + "tools" => value.as_table().is_some_and(|tools| { + tools.values().all(|version| match version { + toml::Value::String(_) => true, + toml::Value::Array(versions) => { + versions.iter().all(|v| matches!(v, toml::Value::String(_))) + } + _ => false, + }) + }), + _ => false, + }) +} + +/// Whether any decoded string (table key or value, at any depth) contains +/// Tera template syntax. Used to catch escaped delimiters (e.g. `{{`) +/// that a raw-text scan misses but that still render after TOML parsing. +pub(crate) fn toml_value_has_template(value: &toml::Value) -> bool { + match value { + toml::Value::String(s) => contains_template_syntax(s), + toml::Value::Array(arr) => arr.iter().any(toml_value_has_template), + toml::Value::Table(t) => toml_table_has_template(t), + _ => false, + } +} + +fn toml_table_has_template(table: &toml::Table) -> bool { + table + .iter() + .any(|(k, v)| contains_template_syntax(k) || toml_value_has_template(v)) +} + fn is_tools_sorted(tools: &IndexMap) -> bool { let mut last = None; for k in tools.keys() { @@ -1965,7 +2056,7 @@ fn is_tools_sorted(tools: &IndexMap) -> bool { mod tests { use std::sync::Arc; - use indoc::formatdoc; + use indoc::{formatdoc, indoc}; use insta::{assert_debug_snapshot, assert_snapshot}; use test_log::test; @@ -2532,6 +2623,75 @@ run = 'echo "template"' parse(toml).env_entries().unwrap().into_iter().join("\n") } + #[test] + fn test_is_safe_config_body() { + assert!(is_safe_config_body("")); + assert!(is_safe_config_body(indoc! {r#" + min_version = "2024.1.1" + [tools] + node = "20" + python = ["3.11", "3.12"] + "cargo:eza" = "latest" + "#})); + // tasks are inert until the user explicitly runs one + assert!(is_safe_config_body(indoc! {r#" + [tasks.build] + run = "cargo build" + dir = "src" + env = { FOO = "bar" } + [tasks.test] + depends = ["build"] + run = ["cargo test"] + "#})); + + // templates can execute commands + assert!(!is_safe_config_body(indoc! {r#" + [tools] + node = "{{ exec(command='echo 20') }}" + "#})); + // tool options like postinstall/install_env run code + assert!(!is_safe_config_body(indoc! {r#" + [tools] + node = { version = "20", postinstall = "corepack enable" } + "#})); + assert!(!is_safe_config_body(indoc! {r#" + [tools] + node = [{ version = "20" }] + "#})); + // tasks with templates render (and can exec) while loading + assert!(!is_safe_config_body(indoc! {r#" + [tasks.build] + run = "cargo build" + description = "{{ exec(command='echo hi') }}" + "#})); + // escaped Tera delimiters ({ == '{', } == '}') decode to + // `{{ exec(...) }}` after TOML parsing and must not bypass the check + assert!(!is_safe_config_body( + "[tools]\nnode = \"\\u007b\\u007b exec(command='echo 20') \\u007d\\u007d\"\n" + )); + assert!(!is_safe_config_body( + "[tasks.build]\nrun = \"cargo build\"\ndescription = \"\\u007b\\u007b exec(command='echo hi') \\u007d\\u007d\"\n" + )); + // an escaped delimiter in a key must also be caught + assert!(!is_safe_config_body( + "[tasks]\n\"\\u007b\\u007b exec() \\u007d\\u007d\" = { run = \"x\" }\n" + )); + // anything beyond min_version/tools/tasks requires trust + for body in [ + "[env]\nFOO = \"bar\"", + "[task_config]\nincludes = [\"tasks.toml\"]", + "[hooks]\nenter = \"echo hi\"", + "[settings]\nparanoid = false", + "[alias]\nnode = \"asdf:foo/bar\"", + "[plugins]\nfoo = \"https://example.com/foo.git\"", + "env_file = \".env\"", + ] { + assert!(!is_safe_config_body(body), "should require trust: {body}"); + } + // invalid toml falls back to the normal trust + parse flow + assert!(!is_safe_config_body("[tools")); + } + #[tokio::test] async fn test_table_syntax_preserves_registry_defaults() { // Test for #8039: table syntax like `ansible = { version = "latest" }` diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index e702087bd1..5fc2c228f8 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -305,6 +305,14 @@ pub fn config_trust_root(path: &Path) -> PathBuf { } } +/// Whether the file or its trust root has been trusted. +/// +/// Unlike a passing [`trust_check`], this is false for files that merely do +/// not *need* trust (e.g. safe configs loaded without it). +pub fn is_path_trusted(path: &Path) -> bool { + is_trusted(&config_trust_root(path)) || is_trusted(path) +} + pub fn trust_check(path: &Path) -> eyre::Result<()> { static MUTEX: Mutex<()> = Mutex::new(()); let _lock = MUTEX.lock().unwrap(); // Prevent multiple checks at once so we don't prompt multiple times for the same path @@ -312,7 +320,7 @@ pub fn trust_check(path: &Path) -> eyre::Result<()> { let default_cmd = String::new(); let args = env::ARGS.read().unwrap(); let cmd = args.get(1).unwrap_or(&default_cmd).as_str(); - if is_trusted(&config_root) || is_trusted(path) || cmd == "trust" || cfg!(test) { + if is_path_trusted(path) || cmd == "trust" || cfg!(test) { return Ok(()); } if cmd != "hook-env" && !is_ignored(&config_root) && !is_ignored(path) { diff --git a/src/config/mod.rs b/src/config/mod.rs index 48a8bd132a..473a51a6a6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,7 +20,7 @@ use crate::cli::version; use crate::config::config_file::idiomatic_version::IdiomaticVersionFile; use crate::config::config_file::min_version::MinVersionSpec; use crate::config::config_file::mise_toml::{MiseToml, Tasks}; -use crate::config::config_file::{ConfigFile, config_trust_root, trust_check}; +use crate::config::config_file::{ConfigFile, config_trust_root, is_path_trusted, trust_check}; use crate::config::env_directive::{EnvResolveOptions, EnvResults, ToolsFilter}; use crate::config::tracking::Tracker; use crate::env::{MISE_DEFAULT_CONFIG_FILENAME, MISE_DEFAULT_TOOL_VERSIONS_FILENAME}; @@ -542,7 +542,12 @@ impl Config { // to parse for trusted files. Untrusted non-MiseToml files (like // .tool-versions) don't need trust and will parse fine regardless. let trust_root = config_file::config_trust_root(&path); - if !config_file::is_trusted(&trust_root) && !config_file::is_trusted(&path) { + if !config_file::is_trusted(&trust_root) + && !config_file::is_trusted(&path) + // safe mise.toml files load without a trust marker, so a missing + // marker doesn't mean they should be skipped here + && !MiseToml::path_is_trust_exempt(&path) + { debug!("skipping untrusted tracked config: {}", display_path(&path)); continue; } @@ -2533,6 +2538,9 @@ async fn load_file_tasks( let config_root = Arc::new(config_root.to_path_buf()); let cf_root = cf.config_root(); let task_config_dir = cf.task_config().dir.clone(); + // a config can only vouch for task include files when it was actually + // trusted — safe configs load without trust and cannot vouch for anything + let require_task_include_trust = !is_path_trusted(cf.get_path()); for include in includes { let paths = if include.starts_with("git::") { @@ -2547,7 +2555,7 @@ async fn load_file_tasks( &config_root, &task_config_dir, templates, - false, + require_task_include_trust, ) .await?; if is_global_task_include_path(&path) { @@ -2600,7 +2608,9 @@ pub async fn load_tasks_in_dir( templates: &IndexMap, ) -> Result> { let configs = configs_at_root(dir, config_files); - let require_task_include_trust = configs.is_empty(); + // a config can only vouch for task include files when it was actually + // trusted — safe configs load without trust and cannot vouch for anything + let require_task_include_trust = !configs.iter().any(|cf| is_path_trusted(cf.get_path())); let (includes, resolve_dir) = configs .iter() @@ -2661,12 +2671,30 @@ pub async fn load_tasks_in_dir( } fn trust_check_task_include(path: &Path, require_trust: bool) -> Result<()> { - if require_trust && !is_global_task_include_path(path) { + if require_trust && !is_global_task_include_path(path) && task_include_requires_trust(path) { trust_check(path)?; } Ok(()) } +/// Template-free task files are inert at load time: TOML and `#MISE` headers +/// are only parsed, and the scripts themselves only run on an explicit +/// `mise run`. Templates are what can execute code while tasks load (via +/// exec() etc. when task fields render), so only files containing template +/// syntax need trust. Paranoid mode keeps requiring trust for everything. +fn task_include_requires_trust(path: &Path) -> bool { + if Settings::try_get().is_ok_and(|settings| settings.paranoid) { + return true; + } + let Ok(body) = file::read_to_string(path) else { + // can't read it — fall back to requiring trust + return true; + }; + // literal delimiters, plus escaped ones (e.g. `{{`) that decode to + // templates after TOML parsing and would render at load time + contains_template_syntax(&body) || crate::task::file_has_decoded_template(path, &body) +} + async fn load_task_file( config: &Arc, path: &Path, diff --git a/src/task/mod.rs b/src/task/mod.rs index 09ccf2ee7b..a6acf3fd68 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -504,6 +504,41 @@ pub struct Task { pub trailing_args: Vec, } +/// Parse the `#MISE key=value` (and `// MISE`, `:: MISE`, `[MISE]`) header +/// lines out of a task script into their decoded TOML values. +fn parse_mise_header_toml(body: &str) -> Result> { + body.lines() + .filter_map(|line| { + regex!(r"^(?:#|//|::)(?:MISE| ?\[MISE\]) ([a-z0-9_.-]+\s*=\s*[^\n]+)$").captures(line) + }) + .map(|captures| captures.extract().1) + .map(|[toml]| { + toml::de::from_str::(toml) + .map_err(|e| eyre::eyre!("failed to parse task header TOML {toml:?}: {e}")) + }) + .collect() +} + +/// Whether a task include file contains Tera template syntax only after TOML +/// decoding (e.g. `{{` written as `{{`). Such escapes pass a +/// raw-text scan but decode to real templates that render — and can `exec()` — +/// at load time, so they must still require trust. `.toml` task files are +/// checked whole; script files are checked through their `#MISE` headers (the +/// only part parsed and rendered at load). +pub(crate) fn file_has_decoded_template(path: &Path, body: &str) -> bool { + use crate::config::config_file::mise_toml::toml_value_has_template; + if path.extension().is_some_and(|e| e == "toml") { + // Unparseable TOML won't load as a task file (it errors before any + // render), so it doesn't need trust on this account. + toml::from_str::(body).is_ok_and(|v| toml_value_has_template(&v)) + } else { + parse_mise_header_toml(body) + .unwrap_or_default() + .iter() + .any(toml_value_has_template) + } +} + impl Task { pub fn new(path: &Path, prefix: &Path, config_root: &Path) -> Result { Ok(Self { @@ -521,18 +556,7 @@ impl Task { config_root: &Path, ) -> Result { let mut task = Task::new(path, prefix, config_root)?; - let info = file::read_to_string(path)? - .lines() - .filter_map(|line| { - regex!(r"^(?:#|//|::)(?:MISE| ?\[MISE\]) ([a-z0-9_.-]+\s*=\s*[^\n]+)$") - .captures(line) - }) - .map(|captures| captures.extract().1) - .map(|[toml]| { - toml::de::from_str::(toml) - .map_err(|e| eyre::eyre!("failed to parse task header TOML {toml:?}: {e}")) - }) - .collect::>>()? + let info = parse_mise_header_toml(&file::read_to_string(path)?)? .into_iter() .filter_map(|toml| toml.as_table().cloned()) .flatten() @@ -2307,6 +2331,33 @@ mod tests { }); } + #[test] + fn test_file_has_decoded_template() { + use super::file_has_decoded_template; + let toml = Path::new("ci.toml"); + let script = Path::new("script.sh"); + + // plain template-free includes do not need trust + assert!(!file_has_decoded_template( + toml, + "[hello]\nrun = \"echo hi\"\ndescription = \"a plain task\"\n" + )); + assert!(!file_has_decoded_template( + script, + "#!/usr/bin/env bash\n#MISE description=\"a plain task\"\necho hi\n" + )); + + // escaped delimiters ({ == '{', } == '}') decode to a template + assert!(file_has_decoded_template( + toml, + "[hello]\nrun = \"echo hi\"\ndescription = \"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\n" + )); + assert!(file_has_decoded_template( + script, + "#!/usr/bin/env bash\n#MISE description=\"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\necho hi\n" + )); + } + #[cfg(unix)] fn take_captured_fields() -> Option> { CAPTURED_PARSER_FIELDS.with(|captured| captured.lock().unwrap().take())