diff --git a/e2e/helpers/scripts/git_http_backend_server.py b/e2e/helpers/scripts/git_http_backend_server.py index d9b7062b146..dfefb478644 100644 --- a/e2e/helpers/scripts/git_http_backend_server.py +++ b/e2e/helpers/scripts/git_http_backend_server.py @@ -14,8 +14,9 @@ from pathlib import Path class GitHTTPHandler(http.server.BaseHTTPRequestHandler): - def __init__(self, *args, repo_dir=None, upload_pack_count_file=None, **kwargs): + def __init__(self, *args, repo_dir=None, request_log=None, upload_pack_count_file=None, **kwargs): self.repo_dir = repo_dir + self.request_log = request_log self.upload_pack_count_file = upload_pack_count_file super().__init__(*args, **kwargs) @@ -26,6 +27,10 @@ def do_POST(self): self.handle_git_request() def handle_git_request(self): + if self.request_log: + with open(self.request_log, 'a') as log: + log.write(f'{self.command} {self.path}\n') + # Set up environment for git-http-backend env = os.environ.copy() env['GIT_PROJECT_ROOT'] = str(self.repo_dir) @@ -111,7 +116,7 @@ def handle_git_request(self): except Exception as e: self.send_error(500, f"Server error: {str(e)}") -def create_test_repo(repo_path): +def create_test_repo(repo_path, server_port): """Create a minimal test repository""" # Create a regular (non-bare) repository subprocess.run(['git', 'init', repo_path], check=True) @@ -128,6 +133,15 @@ def create_test_repo(repo_path): remote_task_file.write_text('#!/usr/bin/env bash\necho "remote task executed"\n') remote_task_file.chmod(0o755) + remote_task_dir = Path(repo_path) / 'xtasks' / 'remote' + remote_task_dir.mkdir(parents=True) + nested_remote_file = remote_task_dir / 'nested' + nested_remote_file.write_text( + '#!/usr/bin/env bash\n' + 'echo "nested remote task executed"\n' + ) + nested_remote_file.chmod(0o755) + snapshot_root = Path(repo_path) / 'xtasks' / 'snapshot' snapshot_parent = snapshot_root / 'parent' / 'snapshot_parent' snapshot_parent.parent.mkdir(parents=True) @@ -165,6 +179,9 @@ def create_test_repo(repo_path): '[toml_table_task]\n' 'run = "echo toml_table_task executed"\n' 'description = "TOML task with table form"\n' + '\n' + '[nested_remote]\n' + f'file = "git::http://localhost:{server_port}/repo.git//xtasks/remote/nested"\n' ) # A standalone toml file in a sibling directory to test the @@ -203,18 +220,21 @@ def start_server(port=0): repo_path = temp_dir / 'repo' upload_pack_count_path = os.environ.get('MISE_GIT_HTTP_UPLOAD_PACK_COUNT_FILE') upload_pack_count_file = Path(upload_pack_count_path) if upload_pack_count_path else None + request_log_path = os.environ.get('MISE_GIT_HTTP_REQUEST_LOG') + request_log = Path(request_log_path) if request_log_path else None if upload_pack_count_file is not None: upload_pack_count_file.parent.mkdir(parents=True, exist_ok=True) upload_pack_count_file.write_text('') - - print(f"Creating test repository at {repo_path}") - create_test_repo(str(repo_path)) + if request_log is not None: + request_log.parent.mkdir(parents=True, exist_ok=True) + request_log.write_text('') # Create handler with repo directory def handler(*args, **kwargs): return GitHTTPHandler( *args, repo_dir=temp_dir, + request_log=request_log, upload_pack_count_file=upload_pack_count_file, **kwargs, ) @@ -223,6 +243,8 @@ def handler(*args, **kwargs): # This avoids race conditions between finding and binding with socketserver.TCPServer(("", port), handler) as httpd: actual_port = httpd.server_address[1] + print(f"Creating test repository at {repo_path}") + create_test_repo(str(repo_path), actual_port) print(f"Git HTTP server running on port {actual_port}") print(f"Repository URL: http://localhost:{actual_port}/repo.git") @@ -254,6 +276,8 @@ def handler(*args, **kwargs): ready_file.unlink(missing_ok=True) if upload_pack_count_file is not None: upload_pack_count_file.unlink(missing_ok=True) + if request_log is not None: + request_log.unlink(missing_ok=True) if __name__ == '__main__': port = int(sys.argv[1]) if len(sys.argv) > 1 else 0 diff --git a/e2e/helpers/scripts/http_test_server.py b/e2e/helpers/scripts/http_test_server.py index bdd41021316..15d60755135 100644 --- a/e2e/helpers/scripts/http_test_server.py +++ b/e2e/helpers/scripts/http_test_server.py @@ -34,6 +34,20 @@ def do_GET(self): self.end_headers() content = '#!/usr/bin/env bash\necho "running mytask"\n' self.wfile.write(content.encode('utf-8')) + elif self.path == '/test/remote-template': + if marker := os.environ.get('MISE_HTTP_REQUEST_MARKER'): + Path(marker).touch() + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + content = ( + '#!/usr/bin/env bash\n' + '#MISE description="{{ exec(command=\'touch $MISE_REMOTE_TEMPLATE_MARKER\') }}"\n' + '#MISE depends=["remote_dep"]\n' + '#USAGE flag "--remote-flag" help="Remote usage flag"\n' + 'echo "remote template task ran"\n' + ) + self.wfile.write(content.encode('utf-8')) elif self.path == '/test/remote-changing': TestFileHandler.changing_remote_revision += 1 revision = TestFileHandler.changing_remote_revision diff --git a/e2e/tasks/test_task_remote_git_includes b/e2e/tasks/test_task_remote_git_includes index 55c2827989f..e829ac51566 100755 --- a/e2e/tasks/test_task_remote_git_includes +++ b/e2e/tasks/test_task_remote_git_includes @@ -8,15 +8,17 @@ PORT_FILE="$TMPDIR/mise_git_http_port" READY_FILE="$TMPDIR/mise_git_http_ready" INFO_FILE="$TMPDIR/mise_git_http_info" UPLOAD_PACK_COUNT_FILE="$TMPDIR/mise_git_http_upload_pack_count" +REQUEST_LOG="$TMPDIR/mise_git_http_requests" # Clean up any previous server files -rm -f "$PORT_FILE" "$READY_FILE" "$INFO_FILE" "$UPLOAD_PACK_COUNT_FILE" +rm -f "$PORT_FILE" "$READY_FILE" "$INFO_FILE" "$UPLOAD_PACK_COUNT_FILE" "$REQUEST_LOG" # Start local git HTTP server with OS-assigned port to avoid race conditions MISE_GIT_HTTP_PORT_FILE="$PORT_FILE" \ MISE_GIT_HTTP_READY_FILE="$READY_FILE" \ MISE_GIT_HTTP_INFO_FILE="$INFO_FILE" \ MISE_GIT_HTTP_UPLOAD_PACK_COUNT_FILE="$UPLOAD_PACK_COUNT_FILE" \ + MISE_GIT_HTTP_REQUEST_LOG="$REQUEST_LOG" \ python3 "${TEST_ROOT}/helpers/scripts/git_http_backend_server.py" 0 & SERVER_PID=$! @@ -52,7 +54,7 @@ git init # Ensure cleanup on exit cleanup() { kill "$SERVER_PID" 2>/dev/null || true - rm -f "$PORT_FILE" "$READY_FILE" "$INFO_FILE" "$UPLOAD_PACK_COUNT_FILE" + rm -f "$PORT_FILE" "$READY_FILE" "$INFO_FILE" "$UPLOAD_PACK_COUNT_FILE" "$REQUEST_LOG" } trap cleanup EXIT @@ -67,6 +69,11 @@ includes = [ ] EOF +# Safe-mode parsing must reject an untrusted remote include before cloning it. +: >"$REQUEST_LOG" +assert_fail "env -u CI -u GITHUB_ACTION -u GITHUB_ACTIONS MISE_SAFE=1 MISE_TRUSTED_CONFIG_PATHS=$TMPDIR/not-trusted MISE_YES=0 mise tasks ls" "fetching remote task include git::" +assert_fail "test -s '$REQUEST_LOG'" + # The xtasks/lint directory contains multiple task files # We should be able to see and run them assert_contains "mise tasks" "remote-task" @@ -164,6 +171,18 @@ assert_contains "mise tasks" "toml_table_task" assert_contains "mise run toml_task" "toml_task executed" assert_contains "mise run toml_table_task" "toml_table_task executed" +# A remote TOML include may declare another remote file task. Preserve the +# local config as trust provenance through both remote layers. +NESTED_ARTIFACT_TMPDIR="$TMPDIR/nested-remote-git-artifacts" +mkdir -p "$NESTED_ARTIFACT_TMPDIR" +assert_contains "TMPDIR=$NESTED_ARTIFACT_TMPDIR MISE_TASK_REMOTE_NO_CACHE=true mise run nested_remote" "nested remote task executed" +assert_directory_empty "$NESTED_ARTIFACT_TMPDIR" + +PROVENANCE_STATE_DIR="$TMPDIR/remote-git-provenance-state" +mkdir -p "$PROVENANCE_STATE_DIR" +assert_contains "MISE_STATE_DIR=$PROVENANCE_STATE_DIR MISE_TRUSTED_CONFIG_PATHS='' MISE_YES=1 mise run nested_remote" "nested remote task executed" +assert "find '$PROVENANCE_STATE_DIR/trusted-configs' -mindepth 1 \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' '" "1" + mise cache clear # Clear cache to force redownload ################################################################################# diff --git a/e2e/tasks/test_task_remote_metadata_trust b/e2e/tasks/test_task_remote_metadata_trust new file mode 100644 index 00000000000..c38f9853ef1 --- /dev/null +++ b/e2e/tasks/test_task_remote_metadata_trust @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# Remote task discovery must trust the defining config before it fetches or +# renders metadata, including through explicitly included task files. + +HTTP_PORT_FILE="$TMPDIR/mise_http_test_port" +MARKER="$TMPDIR/remote-template-executed" +REQUEST_MARKER="$TMPDIR/remote-template-requested" +MISE_HTTP_TEST_PORT_FILE="$HTTP_PORT_FILE" \ + MISE_HTTP_REQUEST_MARKER="$REQUEST_MARKER" \ + python3 "${TEST_ROOT}/helpers/scripts/http_test_server.py" 0 & +HTTP_SERVER_PID=$! + +cleanup() { + kill "$HTTP_SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +wait_for_file "$HTTP_PORT_FILE" "HTTP test server port file" 30 "$HTTP_SERVER_PID" +HTTP_PORT=$(cat "$HTTP_PORT_FILE") + +UNTRUSTED_ENV="env -u CI -u GITHUB_ACTION -u GITHUB_ACTIONS MISE_PARANOID=1 MISE_TRUSTED_CONFIG_PATHS=$TMPDIR/not-trusted MISE_YES=0 MISE_REMOTE_TEMPLATE_MARKER=$MARKER" + +cat <mise.toml +[tasks.remote_template] +file = "http://localhost:${HTTP_PORT}/test/remote-template" + +[tasks.remote_dep] +run = "echo remote dependency" +EOF + +# Both gates report which one refused, so the assertions below can tell a +# blocked fetch apart from a blocked render. +REMOTE_URL="http://localhost:${HTTP_PORT}/test/remote-template" +FETCH_REFUSED="fetching remote task $REMOTE_URL requires its defining config to be trusted" +RENDER_REFUSED="remote task metadata from $REMOTE_URL requires its defining config to be trusted" + +# Safe mode is a code-execution boundary, not a network one: an untrusted +# config can already download arbitrary URLs there through the HTTP-based +# backends, so the fetch is allowed. Rendering the fetched header is what must +# still require trust. +assert_fail "$UNTRUSTED_ENV MISE_SAFE=1 mise tasks ls" "$RENDER_REFUSED" +[[ -e $REQUEST_MARKER ]] || fail "safe mode should still fetch the remote task" +# belt and braces: in safe mode the tera exec stub would also stop this +[[ ! -e $MARKER ]] || fail "safe-mode remote metadata template rendered without trust" +rm -f "$REQUEST_MARKER" + +# Outside paranoid mode a plain-tasks config body parses trust-exempt, so these +# failures come from the fetch gate rather than a parse-time trust check. The +# safe-mode run above populated the remote-task cache, so force no-cache to +# keep "no request was made" meaningful. +NON_PARANOID_UNTRUSTED_ENV="env -u CI -u GITHUB_ACTION -u GITHUB_ACTIONS MISE_TRUSTED_CONFIG_PATHS=$TMPDIR/not-trusted MISE_YES=0 MISE_TASK_REMOTE_NO_CACHE=true MISE_REMOTE_TEMPLATE_MARKER=$MARKER" +assert_fail "$NON_PARANOID_UNTRUSTED_ENV mise tasks ls" "$FETCH_REFUSED" +assert_fail "$NON_PARANOID_UNTRUSTED_ENV mise tasks info remote_template" "$FETCH_REFUSED" +assert_fail "$NON_PARANOID_UNTRUSTED_ENV mise tasks validate remote_template" "$FETCH_REFUSED" +assert_fail "$NON_PARANOID_UNTRUSTED_ENV mise tasks deps remote_template" "$FETCH_REFUSED" +# Cache inspection is passive discovery too and must honor the same gate. +assert_fail "$NON_PARANOID_UNTRUSTED_ENV mise cache task remote_template" "$FETCH_REFUSED" +[[ ! -e $REQUEST_MARKER ]] || fail "remote task was fetched despite the untrusted fetch gate" +[[ ! -e $MARKER ]] || fail "remote metadata template executed despite the untrusted fetch gate" + +# Passive discovery authorizes, it must not acquire: even under MISE_YES=1 a +# listing must not persist trust for the whole config root. +YES_STATE_DIR="$TMPDIR/yes-trust-state" +mkdir -p "$YES_STATE_DIR" +assert_fail "env -u CI -u GITHUB_ACTION -u GITHUB_ACTIONS MISE_TRUSTED_CONFIG_PATHS=$TMPDIR/not-trusted MISE_YES=1 MISE_STATE_DIR=$YES_STATE_DIR MISE_TASK_REMOTE_NO_CACHE=true mise tasks ls" "$FETCH_REFUSED" +assert "find '$YES_STATE_DIR/trusted-configs' -mindepth 1 2>/dev/null | wc -l | tr -d ' '" "0" + +# watch expands task dependencies (fetching remote tasks) before any run is +# spawned, so safe mode must refuse it before discovery starts. +assert_fail "$UNTRUSTED_ENV MISE_SAFE=1 MISE_TASK_REMOTE_NO_CACHE=true mise watch remote_template" "disabled in safe mode" +[[ ! -e $REQUEST_MARKER ]] || fail "safe-mode watch fetched an untrusted remote task" + +# no-cache so this re-fetches rather than reusing the safe-mode download above +assert_succeed "$UNTRUSTED_ENV mise trust mise.toml" +assert_succeed "$UNTRUSTED_ENV MISE_TASK_REMOTE_NO_CACHE=true mise tasks ls" +[[ -e $REQUEST_MARKER ]] || fail "trusted passive discovery did not fetch the remote task" +[[ -e $MARKER ]] || fail "trusted remote metadata template did not render" + +# Trust gets the header past the render gate, but safe mode still refuses the +# code execution inside it, exactly as it does for a local task template. +assert_fail "$UNTRUSTED_ENV MISE_SAFE=1 mise tasks ls" "exec() is disabled in safe mode" +assert_succeed "$UNTRUSTED_ENV mise untrust mise.toml" + +# A template-free remote task is inert once fetched, so safe mode can list it +# without trust. +PLAIN_DIR="$TMPDIR/plain-remote" +mkdir -p "$PLAIN_DIR" +cat <"$PLAIN_DIR/mise.toml" +[tasks.remote_plain] +file = "http://localhost:${HTTP_PORT}/test/mytask" +EOF +assert_contains "cd '$PLAIN_DIR' && $UNTRUSTED_ENV MISE_SAFE=1 mise tasks ls" "remote_plain" + +# A trusted config vouches for an explicitly included task file even when that +# file is outside the config's trust root. Preserve that provenance when the +# include declares a remote task, including for temporary no-cache snapshots. +INCLUDED_TASK_DIR="$TMPDIR/included-remote-tasks" +mkdir -p "$INCLUDED_TASK_DIR" +cat <"$INCLUDED_TASK_DIR/tasks.toml" +[included_remote_template] +file = "http://localhost:${HTTP_PORT}/test/remote-template" +EOF +cat <mise.toml +[task_config] +includes = ["$INCLUDED_TASK_DIR/tasks.toml"] +EOF + +assert_succeed "$UNTRUSTED_ENV mise trust mise.toml" +assert_contains "$UNTRUSTED_ENV mise tasks info included_remote_template" "remote_dep" +assert_contains "$UNTRUSTED_ENV MISE_TASK_REMOTE_NO_CACHE=true mise tasks info included_remote_template" "remote_dep" +assert_succeed "$UNTRUSTED_ENV mise untrust mise.toml" diff --git a/src/cli/cache/task.rs b/src/cli/cache/task.rs index 9badce6ebb8..21b6fe68e38 100644 --- a/src/cli/cache/task.rs +++ b/src/cli/cache/task.rs @@ -129,6 +129,7 @@ pub(super) async fn resolve_tasks(task_spec: &str) -> Result<(Arc, Vec, tasks: Vec) -> Result<()> { - let deps = Deps::new(config, tasks.clone()).await?; + let deps = Deps::new_for_display(config, tasks.clone()).await?; // filter out nodes that are not selected let start_indexes = deps.graph.node_indices().filter(|&idx| { let task = &deps.graph[idx]; @@ -181,7 +181,7 @@ impl TasksDeps { /// ``` // async fn print_deps_dot(&self, config: &Arc, tasks: Vec) -> Result<()> { - let deps = Deps::new(config, tasks).await?; + let deps = Deps::new_for_display(config, tasks).await?; miseprintln!( "{:?}", Dot::with_attr_getters( diff --git a/src/cli/tasks/info.rs b/src/cli/tasks/info.rs index c537d513031..c93cdef51e4 100644 --- a/src/cli/tasks/info.rs +++ b/src/cli/tasks/info.rs @@ -51,6 +51,7 @@ impl TasksInfo { // always pass no_cache=false as the command doesn't take no-cache argument // MISE_TASK_REMOTE_NO_CACHE env var is still respected if set TaskFetcher::new(false) + .require_trust_before_fetch() .fetch_tasks(&config, &mut tasks) .await?; let task = &tasks[0]; diff --git a/src/cli/tasks/ls.rs b/src/cli/tasks/ls.rs index a4af17c3a4a..3cc70f18cdf 100644 --- a/src/cli/tasks/ls.rs +++ b/src/cli/tasks/ls.rs @@ -158,6 +158,7 @@ impl TasksLs { // always pass no_cache=false as the command doesn't take no-cache argument // MISE_TASK_REMOTE_NO_CACHE env var is still respected if set TaskFetcher::new(false) + .require_trust_before_fetch() .fetch_tasks(&config, &mut tasks) .await?; diff --git a/src/cli/tasks/validate.rs b/src/cli/tasks/validate.rs index 34f1946c6bb..6e31d19f380 100644 --- a/src/cli/tasks/validate.rs +++ b/src/cli/tasks/validate.rs @@ -70,6 +70,7 @@ impl TasksValidate { // always no_cache=false as the command doesn't take no-cache argument // MISE_TASK_REMOTE_NO_CACHE env var is still respected if set TaskFetcher::new(false) + .require_trust_before_fetch() .fetch_tasks(&config, &mut resolved_tasks) .await?; let all_tasks: BTreeMap = resolved_tasks diff --git a/src/cli/watch.rs b/src/cli/watch.rs index c5a48a5fe7f..2eda96797e3 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -2,7 +2,7 @@ use crate::Result; use crate::cli::Cli; use crate::cli::args::BackendArg; use crate::cmd; -use crate::config::Config; +use crate::config::{Config, Settings}; use crate::dirs; use crate::env; use crate::request_exit; @@ -66,6 +66,8 @@ impl Watch { return Ok(()); } } + Settings::ensure_not_safe("running tasks")?; + let config = Config::get().await?; let ts = ToolsetBuilder::new().build(&config).await?; if let Err(err) = which::which("watchexec") { diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 59231333ead..b5db06d8236 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -476,6 +476,39 @@ pub(crate) fn trust_check(path: &Path) -> eyre::Result<()> { Err(UntrustedConfig(path.into()))? } +/// Whether a passive command may fetch remote content declared by `path`. +/// +/// Unlike [`trust_check`] this only authorizes, it never acquires: passive +/// discovery must not turn `mise tasks ls` into a trust grant for the whole +/// config root, which `trust_check`'s prompt -- or `MISE_YES=1` -- would do. +/// Safe mode waives it, because fetching is network work and safe mode is a +/// code-execution boundary. What the fetched content may then *do* is gated +/// separately by [`trust_check_remote_content`]. +pub(crate) fn remote_fetch_allowed(path: &Path) -> bool { + Settings::safe_mode() || is_path_trusted(path) +} + +/// A trust check that safe mode does not waive. +/// +/// Safe mode makes ordinary config parsing trust-exempt because a config +/// loaded there is inert, and it is a code-execution boundary rather than a +/// network one: an untrusted config can already resolve and download +/// arbitrary URLs through the HTTP-based backends. Remote task *content* is +/// different. Whether it is inert is decided by bytes the config only names, +/// so the payload is invisible where the config is reviewed, and a server can +/// vary it per request. Operations that let that content act therefore need +/// real trust even in safe mode. +pub(crate) fn trust_check_remote_content(path: &Path) -> eyre::Result<()> { + if !remote_content_trust_allows(Settings::safe_mode(), is_path_trusted(path)) { + Err(UntrustedConfig(path.into()))? + } + trust_check(path) +} + +fn remote_content_trust_allows(safe_mode: bool, is_trusted: bool) -> bool { + !safe_mode || is_trusted +} + pub(crate) fn is_trusted(path: &Path) -> bool { let canonicalized_path = match path.canonicalize() { Ok(p) => p, @@ -1296,4 +1329,14 @@ mod tests { Path::new("/tmp/trusted/infra-mise.toml-a1b2c3d4e5f67890.monorepo") ); } + + #[test] + fn test_remote_content_trust_policy() { + // safe mode is the only case that cannot fall through to trust_check's + // prompt, so it must fail closed unless the owner is already trusted + assert!(!remote_content_trust_allows(true, false)); + assert!(remote_content_trust_allows(true, true)); + assert!(remote_content_trust_allows(false, false)); + assert!(remote_content_trust_allows(false, true)); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 5a88c8ec7ea..f52be712b69 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -4561,6 +4561,45 @@ async fn resolve_git_url_to_path(git_url: &str) -> Result { Ok(artifact) } +/// Unlike a remote task `file`, cloning a `git::` include stays gated in safe +/// mode. Passing this gate marks the includes authorized, which clears +/// `require_task_include_trust` for the whole include set, so tasks from the +/// clone then render with no further check -- this gate is all there is. +/// Relaxing it therefore means first giving included tasks their own +/// safe-mode-aware render gate *and* decoupling that from `remote_includes_authorized`. +fn trust_check_remote_task_include(owner: &Path, include: &str) -> Result<()> { + config_file::trust_check_remote_content(owner).wrap_err_with(|| { + format!( + "fetching remote task include {include} requires its defining config {} to be trusted", + display_path(owner) + ) + }) +} + +fn trust_check_remote_task_includes(owner: &Path, includes: &[String]) -> Result { + let mut found_remote = false; + for include in includes + .iter() + .filter(|include| include.starts_with("git::")) + { + trust_check_remote_task_include(owner, include)?; + found_remote = true; + } + Ok(found_remote) +} + +fn preserve_remote_task_trust_source(tasks: &mut [Task], source: &Path) { + for task in tasks { + if task.file.as_ref().is_some_and(|file| { + let file = file.to_string_lossy(); + file.starts_with("git::") || file.starts_with("http://") || file.starts_with("https://") + }) { + task.remote_config_source + .get_or_insert_with(|| source.to_path_buf()); + } + } +} + /// Check if a pattern contains glob metacharacters fn is_glob_pattern(pattern: &str) -> bool { // Check for unescaped glob metacharacters: *, ?, [, ], {, } @@ -4735,6 +4774,7 @@ struct TaskSources { struct CascadedTaskConfig { task_config: TaskConfig, includes_root: PathBuf, + includes_source: Option, } fn merge_cascaded_task_config( @@ -4768,10 +4808,14 @@ fn merge_cascaded_task_config( }) { cascaded.task_config.global_pass_through_env = global_pass_through_env; } - if let Some((includes, root)) = configs + if let Some((includes, root, source)) = configs .iter() .find_map(|cf| match cf.task_config_includes() { - Ok(Some(includes)) => Some(Ok((includes, cf.config_root()))), + Ok(Some(includes)) => Some(Ok(( + includes, + cf.config_root(), + cf.get_path().to_path_buf(), + ))), Ok(None) => None, Err(err) => Some(Err(err)), }) @@ -4779,6 +4823,7 @@ fn merge_cascaded_task_config( { cascaded.task_config.includes = Some(includes); cascaded.includes_root = root; + cascaded.includes_source = Some(source); } Ok(()) } @@ -4808,6 +4853,7 @@ fn cascaded_task_config_for_dir( cascaded = Some(CascadedTaskConfig { task_config: TaskConfig::default(), includes_root: root, + includes_source: None, }); } _ => {} @@ -4909,27 +4955,63 @@ async fn load_task_sources_from_configs( cascaded_task_config }; let is_global = configs.iter().any(|cf| is_global_config(cf.get_path())); - // 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, include_config_precedence) = configs + let (includes, resolve_dir, include_config_precedence, include_owner) = configs .iter() .enumerate() .find_map(|(precedence, cf)| match cf.task_config_includes() { - Ok(Some(includes)) => Some(Ok((includes, cf.config_root(), precedence))), + Ok(Some(includes)) => Some(Ok(( + includes, + cf.config_root(), + precedence, + Some(cf.get_path().to_path_buf()), + ))), Ok(None) => None, Err(err) => Some(Err(err)), }) .transpose()? .or_else(|| { cascaded_task_config.and_then(|tc| { - tc.task_config - .includes - .clone() - .map(|includes| (includes, tc.includes_root.clone(), configs.len())) + tc.task_config.includes.clone().map(|includes| { + ( + includes, + tc.includes_root.clone(), + configs.len(), + tc.includes_source.clone(), + ) + }) }) }) - .unwrap_or_else(|| (default_task_includes(), dir.to_path_buf(), configs.len())); + .unwrap_or_else(|| { + ( + default_task_includes(), + dir.to_path_buf(), + configs.len(), + None, + ) + }); + + let remote_includes_authorized = match include_owner.as_deref() { + Some(owner) => trust_check_remote_task_includes(owner, &includes)?, + None if includes.iter().any(|include| include.starts_with("git::")) => { + bail!("remote task include has no defining config provenance") + } + None => false, + }; + + let vouching_config_source = include_owner + .as_ref() + .filter(|owner| remote_includes_authorized || is_path_trusted(owner)) + .cloned() + .or_else(|| { + if include_owner.is_some() { + return None; + } + configs + .iter() + .find(|cf| is_path_trusted(cf.get_path())) + .map(|cf| cf.get_path().to_path_buf()) + }); + let require_task_include_trust = vouching_config_source.is_none(); // Resolve task defaults once for the config root so inline tasks from // lower-precedence overlay files use the same defaults as file tasks. @@ -5015,6 +5097,9 @@ async fn load_task_sources_from_configs( apply_task_config_rust_cache_default(task, &task_config.rust_cache); task_config.environment.apply(task)?; } + if let Some(source) = &vouching_config_source { + preserve_remote_task_trust_source(&mut loaded, source); + } if is_global || is_global_task_include_path(&p) { mark_tasks_as_global(&mut loaded); } diff --git a/src/task/deps.rs b/src/task/deps.rs index e255544ff1d..accbf2abb68 100644 --- a/src/task/deps.rs +++ b/src/task/deps.rs @@ -127,20 +127,30 @@ fn same_task_without_phase(task: &Task, other: &Task) -> bool { /// manages a dependency graph of tasks so `mise run` knows what to run next impl Deps { pub(crate) async fn new(config: &Arc, tasks: Vec) -> eyre::Result { - Self::new_with_cycle_limit(config, tasks, Some(1)).await + Self::new_with_options(config, tasks, Some(1), false).await } pub(crate) async fn new_for_validation( config: &Arc, tasks: Vec, ) -> eyre::Result { - Self::new_with_cycle_limit(config, tasks, None).await + Self::new_with_options(config, tasks, None, true).await } - async fn new_with_cycle_limit( + /// Build a dependency graph for passive display without allowing remote + /// providers to perform network or Git work from an untrusted config. + pub(crate) async fn new_for_display( + config: &Arc, + tasks: Vec, + ) -> eyre::Result { + Self::new_with_options(config, tasks, Some(1), true).await + } + + async fn new_with_options( config: &Arc, tasks: Vec, cycle_limit: Option, + trust_before_fetch: bool, ) -> eyre::Result { let mut graph = DiGraph::new(); let mut indexes = HashMap::new(); @@ -165,7 +175,11 @@ impl Deps { } let all_tasks_to_run = resolve_depends(config, tasks).await?; let no_cache = Settings::get().task.remote_no_cache.unwrap_or(false); - let fetcher = TaskFetcher::new(no_cache); + let fetcher = if trust_before_fetch { + TaskFetcher::new(no_cache).require_trust_before_fetch() + } else { + TaskFetcher::new(no_cache) + }; while let Some(mut a) = stack.pop() { if seen.contains(&a) { // prevent infinite loop diff --git a/src/task/mod.rs b/src/task/mod.rs index 293ed8d1336..ed95adebcb1 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -787,6 +787,11 @@ pub(crate) struct Task { #[serde(skip)] pub remote_file_source: Option, + /// Config file that declared a remote task. The fetched task's local path + /// is not the trust authority for its metadata. + #[serde(skip)] + pub remote_config_source: Option, + /// Block reads, writes, network, and env vars #[serde(default)] pub deny_all: bool, @@ -1058,6 +1063,36 @@ pub(crate) fn file_has_decoded_template(path: &Path, body: &str) -> bool { } } +/// Check decoded remote script header values regardless of the fetched path's +/// extension. A remote script may legitimately end in `.toml`. +pub(crate) fn script_header_has_decoded_template(body: &str) -> bool { + use crate::config::config_file::mise_toml::toml_value_has_template; + scan_mise_header_entries(file::strip_utf8_bom(body)) + .into_iter() + .filter_map(|entry| entry.parse_toml().ok()) + .any(|value| toml_value_has_template(&value)) +} + +/// Whether a fetched remote task header can act before anyone runs the task. +/// +/// Two channels can. A decoded `#MISE` value containing a template renders +/// during discovery, and a `#USAGE include` reads an arbitrary file while the +/// spec is parsed for display. Both must wait for the defining config to be +/// trusted; everything else in the header is inert until `mise run`. +pub(crate) fn remote_header_requires_trust(body: &str) -> bool { + script_header_has_decoded_template(body) || script_usage_includes_file(body) +} + +fn script_usage_includes_file(body: &str) -> bool { + extract_usage_from_comments(file::strip_utf8_bom(body)) + .lines() + .any(|line| { + line.trim_start() + .strip_prefix("include") + .is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) + }) +} + fn parse_task_script_usage(file: &Path) -> usage::Result { let script = std::fs::read_to_string(file)?; // Same reason as the `#MISE` header scan: `#USAGE` on line 1 is invisible behind a mark. @@ -3156,6 +3191,7 @@ impl Default for Task { usage: "".to_string(), timeout: None, remote_file_source: None, + remote_config_source: None, deny_all: false, deny_read: false, deny_write: false, @@ -3833,6 +3869,50 @@ rust_cache = { unknown = true } assert_eq!(env.get("OTHER").map(String::as_str), Some("keep")); } + #[test] + fn test_script_header_has_decoded_template() { + use super::script_header_has_decoded_template; + + assert!(!script_header_has_decoded_template( + "#!/usr/bin/env bash\n#MISE description=\"a plain task\"\necho hi\n" + )); + + // escaped delimiters decode to a template the render would evaluate + assert!(script_header_has_decoded_template( + "#!/usr/bin/env bash\n#MISE description=\"\\u007b\\u007b exec(command='x') \\u007d\\u007d\"\necho hi\n" + )); + + // fetched remote files keep the source name, so a `.toml` suffix must + // not change how the header is read: this is scanned as a script header + // because that is how the fetcher parses every remote task file + assert!(script_header_has_decoded_template( + "#MISE description=\"{{ exec(command='x') }}\"\n" + )); + + // one unparseable entry must not hide a template in another + assert!(script_header_has_decoded_template( + "#!/usr/bin/env bash\n#MISE not valid toml\n#MISE description=\"{{ exec(command='x') }}\"\necho hi\n" + )); + } + + #[test] + fn test_remote_header_requires_trust_covers_usage_include() { + use super::remote_header_requires_trust; + + // a `#USAGE include` reads an arbitrary file while the spec is parsed + // for display, so it needs trust even with a template-free `#MISE` + assert!(remote_header_requires_trust( + "#!/usr/bin/env bash\n#USAGE include file=\"/etc/passwd\"\necho hi\n" + )); + // a flag merely named --include is inert + assert!(!remote_header_requires_trust( + "#!/usr/bin/env bash\n#USAGE flag \"--include\" help=\"x\"\necho hi\n" + )); + assert!(!remote_header_requires_trust( + "#!/usr/bin/env bash\n#MISE description=\"plain\"\necho hi\n" + )); + } + #[test] fn test_file_has_decoded_template() { use super::file_has_decoded_template; diff --git a/src/task/task_fetcher.rs b/src/task/task_fetcher.rs index 9b087f26072..428ca5172d6 100644 --- a/src/task/task_fetcher.rs +++ b/src/task/task_fetcher.rs @@ -1,8 +1,10 @@ +use crate::config::config_file::{remote_fetch_allowed, trust_check_remote_content}; use crate::config::{Config, Settings}; -use crate::task::Task; +use crate::errors::Error::UntrustedConfig; use crate::task::task_file_providers::{TaskFileArtifact, TaskFileProvidersBuilder}; +use crate::task::{Task, remote_header_requires_trust}; use dashmap::DashMap; -use eyre::Result; +use eyre::{Result, WrapErr}; use std::{ path::PathBuf, sync::{Arc, LazyLock, Mutex}, @@ -65,11 +67,21 @@ fn take_remote_task_artifacts() -> Vec>> { /// Handles fetching remote task files and converting them to local paths pub(crate) struct TaskFetcher { no_cache: bool, + trust_before_fetch: bool, } impl TaskFetcher { pub(crate) fn new(no_cache: bool) -> Self { - Self { no_cache } + Self { + no_cache, + trust_before_fetch: false, + } + } + + /// Require trust before a remote provider performs network or Git work. + pub(crate) fn require_trust_before_fetch(mut self) -> Self { + self.trust_before_fetch = true; + self } /// Fetch remote task files, converting remote paths to local cached paths @@ -93,6 +105,31 @@ impl TaskFetcher { } let original = t.clone(); + let defining_cf = original + .cf + .clone() + .or_else(|| config.config_files.get(&original.config_source).cloned()); + let defining_config_source = original + .remote_config_source + .clone() + .or_else(|| defining_cf.as_ref().map(|cf| cf.get_path().to_path_buf())) + .unwrap_or_else(|| original.config_source.clone()); + + // Safe mode deliberately does not gate the fetch itself: it is a + // code-execution boundary, not a network one, and an untrusted + // config can already download arbitrary URLs there through the + // HTTP-based backends. What must not happen without trust is the + // fetched header acting, which the gate below enforces. + if self.trust_before_fetch && !remote_fetch_allowed(&defining_config_source) { + return Err(UntrustedConfig(defining_config_source.clone())).wrap_err_with( + || { + format!( + "fetching remote task {source} requires its defining config to be trusted" + ) + }, + ); + } + let provider = task_file_providers .get_provider(&source) .ok_or_else(|| eyre::eyre!("No provider found for file: {}", source))?; @@ -127,12 +164,27 @@ impl TaskFetcher { // Parse the downloaded script as a regular file task so all #MISE // metadata is honored. The inline TOML task remains the higher- // precedence overlay, matching local file-task behavior. + let body = crate::file::read_to_string(&local_path).wrap_err_with(|| { + format!("failed to read remote task metadata from {source}") + })?; + let header_requires_trust = remote_header_requires_trust(&body); let mut remote = Task::from_path_unrendered_with_cf( &local_path, prefix, &config_root, original.cf.clone(), - )?; + ) + .wrap_err_with(|| format!("failed to parse remote task metadata from {source}"))?; + // Rendering a template, or resolving a `#USAGE include`, is + // where a remote header acts, so this gate holds even in safe + // mode. + if header_requires_trust { + trust_check_remote_content(&defining_config_source).wrap_err_with(|| { + format!( + "remote task metadata from {source} requires its defining config to be trusted" + ) + })?; + } remote.name.clone_from(&original.name); remote.display_name.clone_from(&original.display_name); @@ -145,13 +197,19 @@ impl TaskFetcher { remote.inherited_env.clone_from(&original.inherited_env); remote.overlay_env.clone_from(&original.overlay_env); remote.overlay_vars.clone_from(&original.overlay_vars); - remote.render(config, &config_root).await?; + remote + .render(config, &config_root) + .await + .wrap_err_with(|| { + format!("failed to render remote task metadata from {source}") + })?; remote.merge_toml_overlay(original.clone()); // Preserve runtime state that is not task metadata and therefore is // intentionally not handled by merge_toml_overlay(). remote.global = original.global; remote.remote_file_source = Some(source); + remote.remote_config_source = Some(defining_config_source); *t = remote; } }